diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 20d9ff62fd9..ab611eb0aa5 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -24,20 +24,15 @@ jobs: uses: tibdex/backport@v2 with: github_token: ${{ secrets.GITHUB_TOKEN }} - title_template: "[AUTO-BACKPORT <%= number %>] <%= title %>" - - - name: wait-for-checks - uses: poseidon/wait-for-status-checks@v0.5.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ignore: test-e2e-longrunning + title_template: "[AUTO-BACKPORT <%= base %>] <%= number %>: <%= title %>" - name: merge backported PR id: merge_backport_pr if: success() run: | - PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --search "[AUTO-BACKPORT ${{ github.event.pull_request.number }}]" --json number --jq '.[0].number') + PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --search "[AUTO-BACKPORT release-[0-9.]+ ] ${{ github.event.pull_request.number }}" --json number --jq '.[0].number') if [ -n "$PR_NUMBER" ]; then + tools/scripts/track-pr pr-${{ github.event.action }} "$PR_NUMBER" gh pr merge $PR_NUMBER --merge --repo ${{ github.repository }} --admin -t "Auto-merged backport PR." else echo "No backport PR found to merge." diff --git a/.github/workflows/track-prs-for-release.yml b/.github/workflows/track-prs-for-release.yml deleted file mode 100644 index 8ea8b5dcd5d..00000000000 --- a/.github/workflows/track-prs-for-release.yml +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: "Track PRs for release" - -on: # yamllint disable-line rule:truthy - pull_request_target: - types: - - closed - - labeled - - unlabeled - branches: - - main - -concurrency: - group: track-prs-for-release - -env: - LOGS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - -# All jobs require a specifically generated token to run, since we want to -# access org-level projects and the normal GITHUB_TOKEN only has access to this -# repository. - -jobs: - handle_merged_pr: - if: github.event.action == 'closed' && github.event.pull_request.merged - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.base_ref }} - - name: Generate token - id: generate_token - uses: tibdex/github-app-token@v2.1.0 - with: - app_id: ${{ secrets.RELEASE_APP_ID }} - private_key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - name: Handle merged PR - env: - CASPER_TOKEN: ${{ secrets.CASPER_TOKEN }} - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - PR_ID: ${{ github.event.pull_request.node_id }} - run: tools/scripts/track-pr pr-merged "$PR_ID" - - handle_labeled_pr: - if: github.event.action == 'labeled' || github.event.action == 'unlabeled' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.base_ref }} - - name: Generate token - id: generate_token - uses: tibdex/github-app-token@v2.1.0 - with: - app_id: ${{ secrets.RELEASE_APP_ID }} - private_key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - name: Handle labeled PR - env: - CASPER_TOKEN: ${{ secrets.CASPER_TOKEN }} - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - PR_ID: ${{ github.event.pull_request.node_id }} - PR_LABEL: ${{ github.event.label.name }} - run: tools/scripts/track-pr pr-${{ github.event.action }} "$PR_ID" "$PR_LABEL" diff --git a/tools/scripts/gql.py b/tools/scripts/gql.py deleted file mode 100644 index e581e2741cb..00000000000 --- a/tools/scripts/gql.py +++ /dev/null @@ -1,269 +0,0 @@ -import os -from typing import Any - -import requests - -GRAPHQL_URL = "https://api.github.com/graphql" - -DEBUG = os.environ.get("DET_DEBUG") == "1" - - -class GraphQLQuery(str): - def __call__(self, **args: Any) -> Any: - if DEBUG: - print("================ GraphQL query") - print(self.strip()) - print(args) - r = requests.post( - GRAPHQL_URL, - headers={"Authorization": "bearer " + os.environ["GITHUB_TOKEN"]}, - json={"query": self, "variables": args}, - ) - r.raise_for_status() - j = r.json() - if DEBUG: - print(j) - try: - return j["data"] - except Exception: - print(j) - raise - - -get_repo_id = GraphQLQuery( - """ -query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - id - } -} -""" -) - -get_pr_id = GraphQLQuery( - """ -query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - } - } -} -""" -) - - -search_projects = GraphQLQuery( - """ -query($owner: String!, $q: String!) { - organization(login: $owner) { - projectsV2(query: $q, first: 100) { - nodes { - id - title - } - } - } -} -""" -) - -get_project_status_field = GraphQLQuery( - """ -query($project: ID!) { - node(id: $project) { - ... on ProjectV2 { - field(name: "Status") { - ... on ProjectV2SingleSelectField { - id - options { - id - name - } - } - } - } - } -} -""" -) - -create_issue = GraphQLQuery( - """ -mutation($repo: ID!, $title: String!, $body: String!) { - createIssue(input: {repositoryId: $repo, title: $title, body: $body}) { - issue { - id - } - } -} -""" -) - -add_item_to_project = GraphQLQuery( - """ -mutation($project: ID!, $item: ID!) { - addProjectV2ItemById(input: {projectId: $project, contentId: $item}) { - item { - id - } - } -} -""" -) - -get_status_field_info = GraphQLQuery( - """ -query($project: ID!) { - node(id: $project) { - ... on ProjectV2 { - field(name: "Status") { - ... on ProjectV2SingleSelectField { - id - options { - id - name - } - } - } - } - } -} -""" -) - -set_project_item_status = GraphQLQuery( - """ -mutation($project: ID!, $item: ID!, $field: ID!, $value: String) { - updateProjectV2ItemFieldValue( - input: { - projectId: $project, itemId: $item, fieldId: $field, value: {singleSelectOptionId: $value} - } - ) { - projectV2Item { - id - } - } -} -""" -) - -get_pr_labels = GraphQLQuery( - """ -query($id: ID!) { - node(id: $id) { - ... on PullRequest { - labels(first: 100) { - nodes { - name - } - } - } - } -} -""" -) - -get_pr_merge_commit_and_url = GraphQLQuery( - """ -query($id: ID!) { - node(id: $id) { - ... on PullRequest { - url - mergeCommit { - oid - } - } - } -} -""" -) - -get_pr_info = GraphQLQuery( - """ -query($id: ID!) { - node(id: $id) { - ... on PullRequest { - number - title - url - body - repository { - owner { - login - } - name - } - } - } -} -""" -) - - -get_pr_title = GraphQLQuery( - """ -query($id: ID!) { - node(id: $id) { - ... on PullRequest { - title - } - } -} -""" -) - - -get_pr_state = GraphQLQuery( - """ -query($id: ID!) { - node(id: $id) { - ... on PullRequest { - state - } - } -} -""" -) - - -delete_project_item = GraphQLQuery( - """ -mutation($project: ID!, $item: ID!) { - deleteProjectV2Item(input: {projectId: $project, itemId: $item}) { - deletedItemId - } -} -""" -) - - -list_project_prs = GraphQLQuery( - """ -query($project: ID!, $after: String) { - node(id: $project) { - ... on ProjectV2 { - items(first: 100, after: $after) { - nodes { - id - fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { - name - } - } - content { - ... on PullRequest { - id - } - } - } - pageInfo { - endCursor - hasNextPage - } - } - } - } -} -""" -) diff --git a/tools/scripts/track-pr b/tools/scripts/track-pr deleted file mode 100755 index ef6ae53cfd4..00000000000 --- a/tools/scripts/track-pr +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env python3 - -# ==== PR merged -# - if label, do cherry-pick -# - if no label, add tracking issue to next release as "Needs testing" - -# ==== PR labeled -# - if open, add PR to current release as "Fix (open)" -# - if closed, remove from next release, do cherry-pick - -# ==== PR unlabeled -# - if open, remove from current release as "Fix (open)" -# - if closed, ??? - -# ==== cherry-pick conflict resolved -# - confirm cherry-pick was actually done -# - remove PR from current release as "Fix (conflict)" -# - add tracking issue to current release as "Needs testing" - -# ==== cherry-pick (internal) -# - run Git to get branches and cherry-pick -# - if success, push branches, add tracking issue to current release as "Needs testing" -# - if fail, notify and add PR to current release as "Fix (conflict)" - -import argparse -import base64 -import os -import re -import subprocess -import sys -from typing import Callable, Optional - -import requests - -import gql - -TEST = os.environ.get("RELEASE_TEST") == "1" - - -ORG = "determined-ai" -CLONED_REMOTE = "origin" -ISSUES_REPO = "release-party-issues-test" if TEST else "release-party-issues" - -CHERRY_PICK_LABEL = "to-cherry-pick" - -NEEDS_TESTING_STATUS = "Needs testing" -FIX_OPEN_STATUS = "Fix (open)" -FIX_CONFLICT_STATUS = "Fix (conflict)" -FIX_UNRELEASED_STATUS = "Fix (unreleased)" -FIX_RELEASED_STATUS = "Fix (released)" - -GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] -CASPER_TOKEN = os.environ.get("CASPER_TOKEN", "") - - -def run(*args, check=True, quiet=False, **kwargs): - kwargs = dict(kwargs) - if not quiet: - print(f"\n================ running: \x1b[36m{args}\x1b[m") - return subprocess.run(args, check=check, **kwargs) - - -def run_capture(*args, **kwargs): - return run( - *args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, **kwargs - ) - - -def make_issue_for_pr(issue_repo_id: str, pr_id: str) -> str: - pr_info = gql.get_pr_info(id=pr_id)["node"] - pr_repo = pr_info["repository"]["name"] - pr_num = pr_info["number"] - pr_title = pr_info["title"] - pr_body = pr_info["body"] - pr_url = pr_info["url"] - title = f"Test {pr_repo}#{pr_num} ({pr_title})" - print(f"Creating tracking issue '{title}'") - return gql.create_issue( - repo=issue_repo_id, title=title, body=f"(copied from {pr_url})\n\n----\n\n{pr_body}" - )["createIssue"]["issue"]["id"] - - -def get_project_status_ids(project_id: str, status: str): - status_info = gql.get_status_field_info(project=project_id)["node"]["field"] - field_id = status_info["id"] - value_id = next(v["id"] for v in status_info["options"] if v["name"] == status) - return field_id, value_id - - -def add_item_to_project(project_id: str, item_id: str, status: str) -> None: - status_field_id, status_value_id = get_project_status_ids(project_id, status) - item_id = gql.add_item_to_project(project=project_id, item=item_id)["addProjectV2ItemById"][ - "item" - ]["id"] - gql.set_project_item_status( - project=project_id, item=item_id, field=status_field_id, value=status_value_id - ) - - -def set_project_item_status(project_id: str, item_id: str, status: str) -> None: - status_field_id, status_value_id = get_project_status_ids(project_id, status) - gql.set_project_item_status( - project=project_id, item=item_id, field=status_field_id, value=status_value_id - ) - - -def set_project_pr_status(project_id: str, pr_id: str, status: str) -> None: - status_field_id, status_value_id = get_project_status_ids(project_id, status) - item_id = project_item_id_for_pr(project_id, pr_id) - gql.set_project_item_status( - project=project_id, item=item_id, field=status_field_id, value=status_value_id - ) - - -def add_tracking_issue_to_project(project_id: str, pr_id: str, status: str) -> None: - issue_repo_id = gql.get_repo_id(owner=ORG, name=ISSUES_REPO)["repository"]["id"] - issue_id = make_issue_for_pr(issue_repo_id, pr_id) - add_item_to_project(project_id, issue_id, status) - - -def find_project(owner: str, query: str, filt: Callable[[dict], bool]) -> dict: - all_projects = gql.search_projects(owner=owner, q=query)["organization"]["projectsV2"]["nodes"] - return next(p for p in all_projects if filt(p)) - - -def next_project_id() -> str: - return find_project( - ORG, - "Next release", - lambda p: p["title"] == ("TEST Next release" if TEST else "Next release"), - )["id"] - - -def current_project_id() -> str: - return find_project( - ORG, - "Current release", - lambda p: p["title"].startswith("TEST Current release" if TEST else "Current release"), - )["id"] - - -def project_item_id_for_pr(project_id: str, pr_id: str): - after_cursor = None - while True: - items = gql.list_project_prs(project=project_id, after=after_cursor)["node"]["items"] - for item in items["nodes"]: - if item["content"] and item["content"]["id"] == pr_id: - return item["id"] - if not items["pageInfo"]["hasNextPage"]: - break - after_cursor = items["pageInfo"]["endCursor"] - return None - - -def cherry_pick_skipping_empty(commit): - out = run_capture("git", "cherry-pick", "-x", commit, check=False) - try: - out.check_returncode() - except subprocess.CalledProcessError: - if "The previous cherry-pick is now empty" in out.stderr: - run("git", "cherry-pick", "--skip") - else: - print(out.stdout) - print(out.stderr) - raise - - -def cherry_pick_pr(pr_id: str) -> None: - pr = gql.get_pr_merge_commit_and_url(id=pr_id)["node"] - pr_commit = pr["mergeCommit"]["oid"] - print(f"Cherry-picking {pr_commit}") - - try: - # Find and fetch the PR commit and both release branches. - branch_pat = re.compile(r"/release-(\d+)\.(\d+)\.(\d+)$") - release_branch = max( - ( - line.split()[1] - for line in run_capture( - "git", "ls-remote", CLONED_REMOTE, "refs/heads/release-*" - ).stdout.splitlines() - ), - key=lambda branch: [int(part) for part in branch_pat.search(branch).groups()], - )[len("refs/heads/") :] - print(f"Found release branch {release_branch}") - - run( - "git", - "fetch", - "--depth=2", - CLONED_REMOTE, - pr_commit, - f"{release_branch}:{release_branch}", - ) - - # Perform the cherry-pick and push. - run("git", "config", "user.email", "automation@determined.ai") - run("git", "config", "user.name", "Determined CI") - run("git", "checkout", release_branch) - cherry_pick_skipping_empty(pr_commit) - run("git", "push", CLONED_REMOTE, f"{release_branch}:{release_branch}") - - print("Cherry-pick succeeded, updating item status") - set_project_pr_status(current_project_id(), pr_id, FIX_UNRELEASED_STATUS) - except subprocess.CalledProcessError: - import traceback - - traceback.print_exc() - print("Cherry-pick failed, adding PR as conflicted") - set_project_pr_status(current_project_id(), pr_id, FIX_CONFLICT_STATUS) - requests.post( - "https://casper.internal.infra.determined.ai/hubot/conflict", - headers={"X-Casper-Token": CASPER_TOKEN}, - json={"url": pr["url"], "logs_url": os.environ.get("LOGS_URL")}, - ) - - -class Actions: - @staticmethod - def pr_merged(pr_id: str): - pr_labels = gql.get_pr_labels(id=pr_id)["node"]["labels"]["nodes"] - print("Labels of merged PR:", [label["name"] for label in pr_labels]) - if any(label["name"] == CHERRY_PICK_LABEL for label in pr_labels): - print("Cherry-picking labeled merged PR") - cherry_pick_pr(pr_id) - else: - title = gql.get_pr_title(id=pr_id)["node"]["title"] - if re.match(r"(feat|fix)\S*:", title, re.IGNORECASE) is not None: - print("Adding feat/fix PR") - elif re.match(r"\S+:", title, re.IGNORECASE) is not None: - print("Skipping non-feat/fix PR") - return - else: - print("Adding PR of unknown type") - - print("Adding merged PR to next release project") - add_tracking_issue_to_project(next_project_id(), pr_id, NEEDS_TESTING_STATUS) - - @staticmethod - def pr_labeled(pr_id: str, label: str): - if label != CHERRY_PICK_LABEL: - return - - state = gql.get_pr_state(id=pr_id)["node"]["state"] - if state == "OPEN": - print("Adding labeled open PR to current release project") - add_item_to_project(current_project_id(), pr_id, FIX_OPEN_STATUS) - elif state == "MERGED": - # TODO Maybe delete the tracking issue in the next release that was - # created when this merged without a label. - print("Cherry-picking labeled merged PR") - add_item_to_project(current_project_id(), pr_id, FIX_OPEN_STATUS) - cherry_pick_pr(pr_id) - elif state == "CLOSED": - print("Ignoring label addition to closed PR") - - @staticmethod - def pr_unlabeled(pr_id: str, label: str): - if label != CHERRY_PICK_LABEL: - return - - state = gql.get_pr_state(id=pr_id)["node"]["state"] - if state == "OPEN": - print("Removing unlabeled open PR from current release project") - gql.delete_project_item(project=current_project_id(), item=pr_id) - else: - print(f"Ignoring label removal from {state.lower()} PR") - - @staticmethod - def cherry_pick_conflict_resolved(pr_id: str): - # TODO Use Git to confirm the cherry-pick was done. - project_id = current_project_id() - set_project_pr_status(project_id, pr_id, FIX_UNRELEASED_STATUS) - - @staticmethod - def release_unreleased_prs(): - after_cursor = None - project = current_project_id() - while True: - items = gql.list_project_prs(project=project, after=after_cursor)["node"]["items"] - print("batch:", len(items["nodes"])) - for item in items["nodes"]: - if item["fieldValueByName"]["name"] == FIX_UNRELEASED_STATUS and item["content"]: - print(project, item["content"]) - add_tracking_issue_to_project( - project, item["content"]["id"], NEEDS_TESTING_STATUS - ) - set_project_item_status(project, item["id"], FIX_RELEASED_STATUS) - if not items["pageInfo"]["hasNextPage"]: - break - after_cursor = items["pageInfo"]["endCursor"] - - -def main(args): - if not args: - print("Must provide an action!") - return 1 - - action = args.pop(0).replace("-", "_") - return getattr(Actions, action)(*args) - - -if __name__ == "__main__": - exit(main(sys.argv[1:]))