From bf87bdb01bed6a0f8ed8b8eaf85802a2b071c6b8 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 23:54:26 +0000 Subject: [PATCH 1/6] Automate PR backport workflow Allow backports to be added via PR comments and automatically processed on merge. - Added 'add-backports' subcommand to append PRs to active release tracking issue. - Added 'find-release-issue' subcommand to locate the tracking issue for a PR. - Added unit tests for new subcommands. - Created 'release_add_backports' reusable workflow. - Updated comment workflow to parse PR comments. - Created 'on_pr_closed' workflow to automate processing on merge. --- .github/workflows/on_comment.yaml | 140 ++++++++++++++++++ .github/workflows/on_issue_comment.yaml | 105 ------------- .github/workflows/on_pr_closed.yaml | 82 ++++++++++ .github/workflows/release_add_backports.yaml | 58 ++++++++ tests/tools/private/release/BUILD.bazel | 18 +++ .../private/release/add_backports_test.py | 101 +++++++++++++ .../release/find_release_issue_test.py | 111 ++++++++++++++ tools/private/release/add_backports.py | 103 +++++++++++++ tools/private/release/find_release_issue.py | 91 ++++++++++++ tools/private/release/release.py | 4 + 10 files changed, 708 insertions(+), 105 deletions(-) create mode 100644 .github/workflows/on_comment.yaml delete mode 100644 .github/workflows/on_issue_comment.yaml create mode 100644 .github/workflows/on_pr_closed.yaml create mode 100644 .github/workflows/release_add_backports.yaml create mode 100644 tests/tools/private/release/add_backports_test.py create mode 100644 tests/tools/private/release/find_release_issue_test.py create mode 100644 tools/private/release/add_backports.py create mode 100644 tools/private/release/find_release_issue.py diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml new file mode 100644 index 0000000000..ed7813adac --- /dev/null +++ b/.github/workflows/on_comment.yaml @@ -0,0 +1,140 @@ +name: "On Comment" + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + # This job always runs to prevent GHA from marking the run as failed when + # all other jobs are skipped. + noop: + runs-on: ubuntu-latest + steps: + - run: echo "No-op" + + parse_comment: + runs-on: ubuntu-latest + if: | + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' + outputs: + command: ${{ steps.parse.outputs.command }} + issue_number: ${{ steps.parse.outputs.issue_number }} + pr_number: ${{ steps.parse.outputs.pr_number }} + backports: ${{ steps.parse.outputs.backports }} + steps: + - name: Parse comment + id: parse + env: + COMMENT_BODY: ${{ github.event.comment.body }} + IS_PR: "${{ github.event.issue.pull_request != null }}" + EVENT_NUMBER: "${{ github.event.issue.number }}" + HAS_RELEASE_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: release') }}" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$IS_PR" = "false" ] && [ "$HAS_RELEASE_LABEL" = "true" ]; then + issue_number=$EVENT_NUMBER + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then + echo "command=create-rc" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=prepare" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then + echo "command=process-backports" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then + args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') + args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') + csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') + if [ -n "$csv" ]; then + echo "command=add-backports" >> "$GITHUB_OUTPUT" + echo "backports=$csv" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + echo "Error: No PRs specified for add-backports." >&2 + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -f "content=-1" + fi + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then + echo "command=promote" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + elif [ "$IS_PR" = "true" ]; then + pr_number=$EVENT_NUMBER + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then + echo "command=pr-backport" >> "$GITHUB_OUTPUT" + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + + call_create_rc: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'create-rc' + uses: ./.github/workflows/release_create_rc.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_prepare: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'prepare' + uses: ./.github/workflows/release_prepare.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_add_backports: + needs: parse_comment + if: | + needs.parse_comment.outputs.command == 'add-backports' || + needs.parse_comment.outputs.command == 'pr-backport' + uses: ./.github/workflows/release_add_backports.yaml + with: + prs: ${{ needs.parse_comment.outputs.command == 'pr-backport' && needs.parse_comment.outputs.pr_number || needs.parse_comment.outputs.backports }} + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_process_backports_after_add: + needs: [parse_comment, call_add_backports] + if: needs.parse_comment.outputs.command == 'add-backports' + uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_process_backports_only: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'process-backports' + uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" + secrets: inherit + + call_promote: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'promote' + uses: ./.github/workflows/release_promote_rc.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml deleted file mode 100644 index 14248d0d7a..0000000000 --- a/.github/workflows/on_issue_comment.yaml +++ /dev/null @@ -1,105 +0,0 @@ -name: "On Issue Comment" - -on: - issue_comment: - types: [created] - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - # This job always runs to prevent GHA from marking the run as failed when - # all other jobs are skipped. - noop: - runs-on: ubuntu-latest - steps: - - run: echo "No-op" - - parse_comment: - runs-on: ubuntu-latest - if: | - github.event.issue.pull_request == null && - contains(github.event.issue.labels.*.name, 'type: release') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR') - outputs: - command: ${{ steps.parse.outputs.command }} - issue_number: ${{ github.event.issue.number }} - backports: ${{ steps.parse.outputs.backports }} - steps: - - name: Parse comment - id: parse - env: - COMMENT_BODY: ${{ github.event.comment.body }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then - echo "command=create-rc" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then - echo "command=prepare" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then - echo "command=process-backports" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then - args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//') - # Strip leading/trailing spaces and commas - args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') - # Replace internal spaces/commas with single comma - csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') - if [ -n "$csv" ]; then - echo "command=add-backports" >> "$GITHUB_OUTPUT" - echo "backports=$csv" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - echo "Error: No PRs specified for add-backports." >&2 - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ - -f "content=-1" - fi - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then - echo "command=promote" >> "$GITHUB_OUTPUT" - else - echo "command=none" >> "$GITHUB_OUTPUT" - fi - - call_create_rc: - needs: parse_comment - if: needs.parse_comment.outputs.command == 'create-rc' - uses: ./.github/workflows/release_create_rc.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - comment_id: "${{ github.event.comment.id }}" - secrets: inherit - - call_prepare: - needs: parse_comment - if: needs.parse_comment.outputs.command == 'prepare' - uses: ./.github/workflows/release_prepare.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - secrets: inherit - - call_process_backports: - needs: parse_comment - if: | - needs.parse_comment.outputs.command == 'process-backports' || - needs.parse_comment.outputs.command == 'add-backports' - uses: ./.github/workflows/release_process_backports.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - add_backports: ${{ needs.parse_comment.outputs.command == 'add-backports' && needs.parse_comment.outputs.backports || '' }} - comment_id: "${{ github.event.comment.id }}" - secrets: inherit - - call_promote: - needs: parse_comment - if: needs.parse_comment.outputs.command == 'promote' - uses: ./.github/workflows/release_promote_rc.yaml - with: - issue: ${{ needs.parse_comment.outputs.issue_number }} - secrets: inherit diff --git a/.github/workflows/on_pr_closed.yaml b/.github/workflows/on_pr_closed.yaml new file mode 100644 index 0000000000..8aacec2bcd --- /dev/null +++ b/.github/workflows/on_pr_closed.yaml @@ -0,0 +1,82 @@ +name: "On PR Closed" + +on: + pull_request: + types: [closed] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + # This job always runs to prevent GHA from marking the run as failed when + # all other jobs are skipped. + noop: + runs-on: ubuntu-latest + steps: + - run: echo "No-op" + + check_if_backport: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + outputs: + should_process: ${{ steps.check.outputs.should_process }} + steps: + - name: Check if PR is a backport candidate + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Check if there is any active release issue + ACTIVE_ISSUES=$(gh issue list --repo ${{ github.repository }} --label "type: release" --state open --json number) + if [ "$ACTIVE_ISSUES" = "[]" ] || [ -z "$ACTIVE_ISSUES" ]; then + echo "No active release tracking issue found. Skipping." + echo "should_process=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Check if PR has "/backport" in comments (only comments, not body) + PR_DATA=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json comments) + + if echo "$PR_DATA" | jq -r '.comments[].body' | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then + echo "Found /backport comment. Proceeding." + echo "should_process=true" >> "$GITHUB_OUTPUT" + else + echo "No /backport comment found. Skipping." + echo "should_process=false" >> "$GITHUB_OUTPUT" + fi + + find_issue: + needs: check_if_backport + if: needs.check_if_backport.outputs.should_process == 'true' + runs-on: ubuntu-latest + outputs: + issue: ${{ steps.find.outputs.issue }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Find Release Issue + id: find + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + bazel run //tools/private/release -- find-release-issue "$PR_NUMBER" + + call_process_backports: + needs: find_issue + if: needs.find_issue.outputs.issue != '' + uses: ./.github/workflows/release_process_backports.yaml + with: + issue: ${{ needs.find_issue.outputs.issue }} + secrets: inherit diff --git a/.github/workflows/release_add_backports.yaml b/.github/workflows/release_add_backports.yaml new file mode 100644 index 0000000000..d9d6d84e34 --- /dev/null +++ b/.github/workflows/release_add_backports.yaml @@ -0,0 +1,58 @@ +name: "Release: Add Backports" + +on: + workflow_dispatch: + inputs: + prs: + description: 'CSV list of PR numbers to add (e.g., 123,456)' + required: true + type: string + issue: + description: 'The Release Tracking Issue Number (optional)' + required: false + type: string + workflow_call: + inputs: + prs: + description: 'CSV list of PR numbers to add (e.g., 123,456)' + required: true + type: string + issue: + description: 'The Release Tracking Issue Number (optional)' + required: false + type: string + +permissions: + issues: write + +jobs: + add_backports: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Add Backports to Tracking Issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRS: ${{ inputs.prs }} + ISSUE: ${{ inputs.issue }} + run: | + ARGS=() + if [ -n "$ISSUE" ]; then + ARGS+=("--issue=$ISSUE") + fi + + # Convert CSV to array + IFS=',' read -r -a pr_array <<< "$PRS" + + bazel run //tools/private/release -- add-backports \ + "${pr_array[@]}" \ + "${ARGS[@]}" diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index e68bbef7d5..0bc9a6ead3 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -8,6 +8,15 @@ py_library( ], ) +py_test( + name = "add_backports_test", + srcs = ["add_backports_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + py_test( name = "changelog_news_test", srcs = ["changelog_news_test.py"], @@ -35,6 +44,15 @@ py_test( ], ) +py_test( + name = "find_release_issue_test", + srcs = ["find_release_issue_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + py_test( name = "git_test", srcs = ["git_test.py"], diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py new file mode 100644 index 0000000000..4a6fafebfe --- /dev/null +++ b/tests/tools/private/release/add_backports_test.py @@ -0,0 +1,101 @@ +import argparse +import unittest +from unittest.mock import patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.add_backports import AddBackports + + +class CmdAddBackportsTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.addCleanup(patch.stopall) + + def test_add_backports_explicit_issue(self): + args = argparse.Namespace(issue=123, prs=[124, 125]) + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +""" + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #124", call_args[1]) + self.assertIn("- [ ] #125", call_args[1]) + # Should also auto-add Tag RC0 + self.assertIn("- [ ] Tag RC0", call_args[1]) + + def test_add_backports_auto_discover_success(self): + args = argparse.Namespace(issue=None, prs=[124]) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +""" + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + self.mock_gh.update_issue_body.assert_called_once_with(456, unittest.mock.ANY) + + def test_add_backports_auto_discover_no_issues(self): + args = argparse.Namespace(issue=None, prs=[124]) + self.mock_gh.get_open_tracking_issues.return_value = [] + + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + + def test_add_backports_auto_discover_multiple_issues(self): + args = argparse.Namespace(issue=None, prs=[124]) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."}, + {"number": 789, "title": "Release 2.2.0", "url": "http://..."}, + ] + + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + + def test_add_backports_no_auto_add_rc_if_pending(self): + args = argparse.Namespace(issue=123, prs=[124]) + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag RC0 +- [ ] Tag Final + +## Backports +""" + result = AddBackports(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertNotIn("Tag RC1", call_args[1]) + # Tag RC0 should still be there + self.assertIn("- [ ] Tag RC0", call_args[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/find_release_issue_test.py b/tests/tools/private/release/find_release_issue_test.py new file mode 100644 index 0000000000..b9bdc29913 --- /dev/null +++ b/tests/tools/private/release/find_release_issue_test.py @@ -0,0 +1,111 @@ +import argparse +import os +import tempfile +import unittest +from unittest.mock import patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.find_release_issue import FindReleaseIssue + + +class CmdFindReleaseIssueTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.addCleanup(patch.stopall) + + def test_find_release_issue_success(self): + args = argparse.Namespace(pr=124) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release + +## Backports +- [ ] #124 | status=pending +""" + result = FindReleaseIssue(args, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + + def test_find_release_issue_not_found(self): + args = argparse.Namespace(pr=124) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release + +## Backports +- [ ] #125 | status=pending +""" + result = FindReleaseIssue(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + + def test_find_release_issue_multiple_issues(self): + args = argparse.Namespace(pr=124) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."}, + {"number": 789, "title": "Release 2.2.0", "url": "http://..."}, + ] + self.mock_gh.get_issue_body.side_effect = [ + """ +## Backports +- [ ] #124 | status=pending +""", + """ +## Backports +- [ ] #124 | status=pending +""", + ] + + result = FindReleaseIssue(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.assertEqual(self.mock_gh.get_issue_body.call_count, 2) + + def test_find_release_issue_no_active_releases(self): + args = argparse.Namespace(pr=124) + self.mock_gh.get_open_tracking_issues.return_value = [] + + result = FindReleaseIssue(args, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + + def test_find_release_issue_github_output(self): + args = argparse.Namespace(pr=124) + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Backports +- [ ] #124 | status=pending +""" + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp: + tmp_path = tmp.name + + try: + with patch.dict(os.environ, {"GITHUB_OUTPUT": tmp_path}): + result = FindReleaseIssue(args, self.mock_gh).run() + + self.assertEqual(result, 0) + with open(tmp_path, "r") as f: + content = f.read() + self.assertEqual(content, "issue=456\n") + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py new file mode 100644 index 0000000000..12234006f7 --- /dev/null +++ b/tools/private/release/add_backports.py @@ -0,0 +1,103 @@ +"""Subcommand to add PRs to the release tracking issue backports checklist.""" + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import ( + add_backports_to_body, + add_rc_task_to_body, + parse_checklist_state, +) + + +class AddBackports: + """Class to add PRs to the release tracking issue.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the add-backports subcommand.""" + args = self.args + + issue_num = args.issue + if not issue_num: + print( + "No issue specified. Trying to auto-discover active release" + " tracking issue..." + ) + try: + open_issues = self.gh.get_open_tracking_issues() + if not open_issues: + print("Error: No open release tracking issues found.") + return 1 + if len(open_issues) > 1: + print( + "Error: Multiple open release tracking issues found." + " Cannot determine active one:" + ) + for issue in open_issues: + print(f"- #{issue['number']}: {issue['title']}") + return 1 + issue_num = open_issues[0]["number"] + print(f"Auto-discovered active release tracking issue: #{issue_num}") + except Exception as e: + print(f"Error auto-discovering tracking issue: {e}") + return 1 + + print(f"Adding backports {args.prs} to tracking issue #{issue_num}...") + try: + body = self.gh.get_issue_body(issue_num) + body = add_backports_to_body(body, args.prs) + state = parse_checklist_state(body) + rc_tags = state.get("rc_tags", {}) + has_pending_rc = any( + not task.checked and task.status != "done" for task in rc_tags.values() + ) + next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0 + if not has_pending_rc: + print( + f"No pending RC task found. Adding 'Tag" + f" RC{next_rc_num}' to checklist..." + ) + body = add_rc_task_to_body(body, next_rc_num) + except ValueError as e: + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Failed to update tracking issue: {e}") + return 1 + + try: + self.gh.update_issue_body(issue_num, body) + print("Successfully updated tracking issue checklist.") + except Exception as e: + print(f"Failed to update tracking issue body: {e}") + return 1 + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for add-backports subcommand.""" + parser = subparsers.add_parser( + "add-backports", + help="Add PRs to the release tracking issue backports checklist.", + ) + parser.add_argument( + "prs", + type=int, + nargs="+", + help="PR numbers to add (positional, space-separated).", + ) + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number. If omitted, will try to auto-discover the active release tracking issue.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/find_release_issue.py b/tools/private/release/find_release_issue.py new file mode 100644 index 0000000000..dfff7cca7c --- /dev/null +++ b/tools/private/release/find_release_issue.py @@ -0,0 +1,91 @@ +"""Subcommand to find the release tracking issue containing a specific PR in its backports.""" + +import os +import sys + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import parse_backports + + +class FindReleaseIssue: + """Class to find the release tracking issue for a PR.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the find-release-issue subcommand.""" + args = self.args + pr_ref = f"#{args.pr}" + + print(f"Searching for active release tracking issue containing PR {pr_ref}...") + try: + open_issues = self.gh.get_open_tracking_issues() + if not open_issues: + print("No open release tracking issues found.") + return 1 + + found_issue = None + for issue in open_issues: + issue_num = issue["number"] + body = self.gh.get_issue_body(issue_num) + backports = parse_backports(body) + + # Check if pr_ref is in backports + if any(item.pr_ref == pr_ref for item in backports): + if found_issue: + print( + f"Error: PR {pr_ref} found in multiple open release" + f" tracking issues: #{found_issue} and #{issue_num}" + ) + return 1 + found_issue = issue_num + + if found_issue: + print(f"Found PR {pr_ref} in tracking issue #{found_issue}") + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + try: + with open(github_output, "a") as f: + f.write(f"issue={found_issue}\n") + print(f"Wrote issue={found_issue} to GITHUB_OUTPUT") + except Exception as e: + print( + f"Failed to write to GITHUB_OUTPUT: {e}", + file=sys.stderr, + ) + return 1 + else: + print(f"issue={found_issue}") + return 0 + else: + print(f"PR {pr_ref} not found in any active release tracking issue.") + return 1 + + except Exception as e: + print(f"Error: {e}") + return 1 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for find-release-issue subcommand.""" + parser = subparsers.add_parser( + "find-release-issue", + help=( + "Find the release tracking issue containing a specific PR in" + " its backports." + ), + ) + parser.add_argument( + "pr", + type=int, + help="PR number to search for.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index a6f3543e71..14b545cb71 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -4,11 +4,13 @@ import os import sys +from tools.private.release.add_backports import AddBackports from tools.private.release.complete_prepare import CompletePrepare from tools.private.release.create_rc import CreateRc from tools.private.release.create_release_branch import CreateReleaseBranch from tools.private.release.create_release_issue import CreateReleaseIssue from tools.private.release.determine_next_version import DetermineNextVersion +from tools.private.release.find_release_issue import FindReleaseIssue from tools.private.release.prepare import Prepare from tools.private.release.process_backports import ProcessBackports from tools.private.release.promote_rc import PromoteRc @@ -19,7 +21,9 @@ Prepare, CompletePrepare, CreateReleaseBranch, + AddBackports, ProcessBackports, + FindReleaseIssue, CreateRc, PromoteRc, ] From 41b5de71eeba76fe1a6824f8fb853ca1fb47cc24 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 4 Jul 2026 02:59:22 +0000 Subject: [PATCH 2/6] chore(release): automate backport processing on PR merge and improve add-backports - Replace find-release-issue with on-pr-merged subcommand to handle the merge event in one step. - Update on_pr_closed.yaml workflow to call on-pr-merged. - Modify add-backports to accept PR numbers, #numbers, and URLs (strictly for the configured repo). - Add tests for new subcommands and URL resolution. --- .github/workflows/on_pr_closed.yaml | 29 ++--- tests/tools/private/release/BUILD.bazel | 14 ++- .../private/release/add_backports_test.py | 13 +- .../release/find_release_issue_test.py | 111 ----------------- tests/tools/private/release/gh_test.py | 61 ++++++++++ .../private/release/on_pr_merged_test.py | 112 +++++++++++++++++ tools/private/release/add_backports.py | 19 ++- tools/private/release/find_release_issue.py | 91 -------------- tools/private/release/gh.py | 48 ++++++++ tools/private/release/on_pr_merged.py | 113 ++++++++++++++++++ tools/private/release/release.py | 4 +- 11 files changed, 385 insertions(+), 230 deletions(-) delete mode 100644 tests/tools/private/release/find_release_issue_test.py create mode 100644 tests/tools/private/release/gh_test.py create mode 100644 tests/tools/private/release/on_pr_merged_test.py delete mode 100644 tools/private/release/find_release_issue.py create mode 100644 tools/private/release/on_pr_merged.py diff --git a/.github/workflows/on_pr_closed.yaml b/.github/workflows/on_pr_closed.yaml index 8aacec2bcd..5dfcb89748 100644 --- a/.github/workflows/on_pr_closed.yaml +++ b/.github/workflows/on_pr_closed.yaml @@ -48,12 +48,14 @@ jobs: echo "should_process=false" >> "$GITHUB_OUTPUT" fi - find_issue: + process_backports: needs: check_if_backport if: needs.check_if_backport.outputs.should_process == 'true' runs-on: ubuntu-latest - outputs: - issue: ${{ steps.find.outputs.issue }} + permissions: + contents: write + issues: write + pull-requests: read steps: - name: Checkout repository uses: actions/checkout@v7 @@ -65,18 +67,17 @@ jobs: with: bazelisk-version: 1.20.0 - - name: Find Release Issue - id: find + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Process Backports env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - bazel run //tools/private/release -- find-release-issue "$PR_NUMBER" - - call_process_backports: - needs: find_issue - if: needs.find_issue.outputs.issue != '' - uses: ./.github/workflows/release_process_backports.yaml - with: - issue: ${{ needs.find_issue.outputs.issue }} - secrets: inherit + bazel run //tools/private/release -- on-pr-merged \ + "$PR_NUMBER" \ + --remote origin \ + --no-dry-run diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 0bc9a6ead3..bc3b6f7e8b 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -45,10 +45,9 @@ py_test( ) py_test( - name = "find_release_issue_test", - srcs = ["find_release_issue_test.py"], + name = "gh_test", + srcs = ["gh_test.py"], deps = [ - ":release_test_helper", "//tools/private/release:release_lib", ], ) @@ -61,6 +60,15 @@ py_test( ], ) +py_test( + name = "on_pr_merged_test", + srcs = ["on_pr_merged_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + py_test( name = "prepare_test", srcs = ["prepare_test.py"], diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py index 4a6fafebfe..7a5d96a23b 100644 --- a/tests/tools/private/release/add_backports_test.py +++ b/tests/tools/private/release/add_backports_test.py @@ -10,9 +10,12 @@ class CmdAddBackportsTest(unittest.TestCase): def setUp(self): _mock_git_and_gh(self) self.addCleanup(patch.stopall) + self.mock_gh.resolve_pr_number.side_effect = lambda x: int( + x.lstrip("#").split("/")[-1] + ) def test_add_backports_explicit_issue(self): - args = argparse.Namespace(issue=123, prs=[124, 125]) + args = argparse.Namespace(issue=123, prs=["124", "125"]) self.mock_gh.get_issue_body.return_value = """ ## Checklist - [ ] Prepare Release @@ -34,7 +37,7 @@ def test_add_backports_explicit_issue(self): self.assertIn("- [ ] Tag RC0", call_args[1]) def test_add_backports_auto_discover_success(self): - args = argparse.Namespace(issue=None, prs=[124]) + args = argparse.Namespace(issue=None, prs=["124"]) self.mock_gh.get_open_tracking_issues.return_value = [ {"number": 456, "title": "Release 2.1.0", "url": "http://..."} ] @@ -54,7 +57,7 @@ def test_add_backports_auto_discover_success(self): self.mock_gh.update_issue_body.assert_called_once_with(456, unittest.mock.ANY) def test_add_backports_auto_discover_no_issues(self): - args = argparse.Namespace(issue=None, prs=[124]) + args = argparse.Namespace(issue=None, prs=["124"]) self.mock_gh.get_open_tracking_issues.return_value = [] result = AddBackports(args, self.mock_gh).run() @@ -64,7 +67,7 @@ def test_add_backports_auto_discover_no_issues(self): self.mock_gh.get_issue_body.assert_not_called() def test_add_backports_auto_discover_multiple_issues(self): - args = argparse.Namespace(issue=None, prs=[124]) + args = argparse.Namespace(issue=None, prs=["124"]) self.mock_gh.get_open_tracking_issues.return_value = [ {"number": 456, "title": "Release 2.1.0", "url": "http://..."}, {"number": 789, "title": "Release 2.2.0", "url": "http://..."}, @@ -77,7 +80,7 @@ def test_add_backports_auto_discover_multiple_issues(self): self.mock_gh.get_issue_body.assert_not_called() def test_add_backports_no_auto_add_rc_if_pending(self): - args = argparse.Namespace(issue=123, prs=[124]) + args = argparse.Namespace(issue=123, prs=["124"]) self.mock_gh.get_issue_body.return_value = """ ## Checklist - [ ] Prepare Release diff --git a/tests/tools/private/release/find_release_issue_test.py b/tests/tools/private/release/find_release_issue_test.py deleted file mode 100644 index b9bdc29913..0000000000 --- a/tests/tools/private/release/find_release_issue_test.py +++ /dev/null @@ -1,111 +0,0 @@ -import argparse -import os -import tempfile -import unittest -from unittest.mock import patch - -from tests.tools.private.release.release_test_helper import _mock_git_and_gh -from tools.private.release.find_release_issue import FindReleaseIssue - - -class CmdFindReleaseIssueTest(unittest.TestCase): - def setUp(self): - _mock_git_and_gh(self) - self.addCleanup(patch.stopall) - - def test_find_release_issue_success(self): - args = argparse.Namespace(pr=124) - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."} - ] - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release - -## Backports -- [ ] #124 | status=pending -""" - result = FindReleaseIssue(args, self.mock_gh).run() - - self.assertEqual(result, 0) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_called_once_with(456) - - def test_find_release_issue_not_found(self): - args = argparse.Namespace(pr=124) - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."} - ] - self.mock_gh.get_issue_body.return_value = """ -## Checklist -- [ ] Prepare Release - -## Backports -- [ ] #125 | status=pending -""" - result = FindReleaseIssue(args, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_called_once_with(456) - - def test_find_release_issue_multiple_issues(self): - args = argparse.Namespace(pr=124) - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."}, - {"number": 789, "title": "Release 2.2.0", "url": "http://..."}, - ] - self.mock_gh.get_issue_body.side_effect = [ - """ -## Backports -- [ ] #124 | status=pending -""", - """ -## Backports -- [ ] #124 | status=pending -""", - ] - - result = FindReleaseIssue(args, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.assertEqual(self.mock_gh.get_issue_body.call_count, 2) - - def test_find_release_issue_no_active_releases(self): - args = argparse.Namespace(pr=124) - self.mock_gh.get_open_tracking_issues.return_value = [] - - result = FindReleaseIssue(args, self.mock_gh).run() - - self.assertEqual(result, 1) - self.mock_gh.get_open_tracking_issues.assert_called_once() - self.mock_gh.get_issue_body.assert_not_called() - - def test_find_release_issue_github_output(self): - args = argparse.Namespace(pr=124) - self.mock_gh.get_open_tracking_issues.return_value = [ - {"number": 456, "title": "Release 2.1.0", "url": "http://..."} - ] - self.mock_gh.get_issue_body.return_value = """ -## Backports -- [ ] #124 | status=pending -""" - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp: - tmp_path = tmp.name - - try: - with patch.dict(os.environ, {"GITHUB_OUTPUT": tmp_path}): - result = FindReleaseIssue(args, self.mock_gh).run() - - self.assertEqual(result, 0) - with open(tmp_path, "r") as f: - content = f.read() - self.assertEqual(content, "issue=456\n") - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py new file mode 100644 index 0000000000..4010d2dca5 --- /dev/null +++ b/tests/tools/private/release/gh_test.py @@ -0,0 +1,61 @@ +import unittest +from unittest.mock import patch + +from tools.private.release.gh import GitHub + + +class GitHubTest(unittest.TestCase): + def setUp(self): + self.gh = GitHub("my-owner/my-repo") + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_digit(self, mock_run_cmd): + # 124 and #125 should resolve immediately without running command + self.assertEqual(self.gh.resolve_pr_number("124"), 124) + self.assertEqual(self.gh.resolve_pr_number("#125"), 125) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_simple(self, mock_run_cmd): + url = "https://github.com/my-owner/my-repo/pull/126" + # Should resolve via regex without calling gh + result = self.gh.resolve_pr_number(url) + self.assertEqual(result, 126) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_with_subpath(self, mock_run_cmd): + url = "https://github.com/my-owner/my-repo/pull/126/files" + # Should resolve via regex without calling gh + result = self.gh.resolve_pr_number(url) + self.assertEqual(result, 126) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_with_query(self, mock_run_cmd): + url = "https://github.com/my-owner/my-repo/pull/126/files?w=1" + # Should resolve via regex without calling gh + result = self.gh.resolve_pr_number(url) + self.assertEqual(result, 126) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_url_other_repo(self, mock_run_cmd): + # URL for a different repo should fail immediately without calling gh + url = "https://github.com/other-owner/other-repo/pull/126" + with self.assertRaises(ValueError) as ctx: + self.gh.resolve_pr_number(url) + self.assertIn("URL is not for the configured repository", str(ctx.exception)) + mock_run_cmd.assert_not_called() + + @patch("tools.private.release.gh.run_cmd") + def test_resolve_pr_number_invalid_ref(self, mock_run_cmd): + # Invalid reference (not number, not URL) should fail + with self.assertRaises(ValueError) as ctx: + self.gh.resolve_pr_number("invalid-ref") + self.assertIn("Could not resolve PR reference", str(ctx.exception)) + mock_run_cmd.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/on_pr_merged_test.py b/tests/tools/private/release/on_pr_merged_test.py new file mode 100644 index 0000000000..a5644c5705 --- /dev/null +++ b/tests/tools/private/release/on_pr_merged_test.py @@ -0,0 +1,112 @@ +import argparse +import unittest +from unittest.mock import MagicMock, patch + +from tests.tools.private.release.release_test_helper import _mock_git_and_gh +from tools.private.release.on_pr_merged import OnPrMerged + + +class CmdOnPrMergedTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.addCleanup(patch.stopall) + + # Mock ProcessBackports + self.mock_process_patcher = patch( + "tools.private.release.on_pr_merged.ProcessBackports" + ) + self.mock_process_class = self.mock_process_patcher.start() + self.mock_process_instance = MagicMock() + self.mock_process_class.return_value = self.mock_process_instance + + def test_on_pr_merged_no_comment(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": "Some comment"}, + {"body": "Another comment /backport_wrong"}, + ] + + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_not_called() + self.mock_process_class.assert_not_called() + + def test_on_pr_merged_has_comment_no_active_release(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": "/backport"}, + ] + self.mock_gh.get_open_tracking_issues.return_value = [] + + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + self.mock_process_class.assert_not_called() + + def test_on_pr_merged_has_comment_not_in_backports(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": " /backport "}, + ] + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release + +## Backports +- [ ] #125 | status=pending +""" + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 1) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + self.mock_process_class.assert_not_called() + + def test_on_pr_merged_success(self): + args = argparse.Namespace(pr=124, remote="origin", dry_run=True) + self.mock_gh.get_pr_comments.return_value = [ + {"body": "/backport"}, + ] + self.mock_gh.get_open_tracking_issues.return_value = [ + {"number": 456, "title": "Release 2.1.0", "url": "http://..."} + ] + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release + +## Backports +- [ ] #124 | status=pending +""" + self.mock_process_instance.run.return_value = 0 + + result = OnPrMerged(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + self.mock_gh.get_pr_comments.assert_called_once_with(124) + self.mock_gh.get_open_tracking_issues.assert_called_once() + self.mock_gh.get_issue_body.assert_called_once_with(456) + + # Verify ProcessBackports was instantiated with correct args + self.mock_process_class.assert_called_once() + called_args = self.mock_process_class.call_args[0][0] + self.assertEqual(called_args.issue, 456) + self.assertEqual(called_args.remote, "origin") + self.assertEqual(called_args.dry_run, True) + self.assertIsNone(called_args.add) + self.assertIsNone(called_args.triggering_comment) + + # Verify ProcessBackports.run() was called + self.mock_process_instance.run.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py index 12234006f7..aac28fab11 100644 --- a/tools/private/release/add_backports.py +++ b/tools/private/release/add_backports.py @@ -44,10 +44,21 @@ def run(self) -> int: print(f"Error auto-discovering tracking issue: {e}") return 1 - print(f"Adding backports {args.prs} to tracking issue #{issue_num}...") + resolved_prs = [] + for pr_ref in args.prs: + try: + pr_num = self.gh.resolve_pr_number(pr_ref) + resolved_prs.append(pr_num) + except Exception as e: + print(f"Error resolving PR ref '{pr_ref}': {e}") + return 1 + + print( + f"Adding backports {resolved_prs} (resolved from {args.prs}) to tracking issue #{issue_num}..." + ) try: body = self.gh.get_issue_body(issue_num) - body = add_backports_to_body(body, args.prs) + body = add_backports_to_body(body, resolved_prs) state = parse_checklist_state(body) rc_tags = state.get("rc_tags", {}) has_pending_rc = any( @@ -85,9 +96,9 @@ def add_parser(cls, subparsers): ) parser.add_argument( "prs", - type=int, + type=str, nargs="+", - help="PR numbers to add (positional, space-separated).", + help="PR references (number, #number, URL, or commitish) to add (positional, space-separated).", ) parser.add_argument( "--issue", diff --git a/tools/private/release/find_release_issue.py b/tools/private/release/find_release_issue.py deleted file mode 100644 index dfff7cca7c..0000000000 --- a/tools/private/release/find_release_issue.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Subcommand to find the release tracking issue containing a specific PR in its backports.""" - -import os -import sys - -from tools.private.release.gh import GitHub -from tools.private.release.release_issue import parse_backports - - -class FindReleaseIssue: - """Class to find the release tracking issue for a PR.""" - - def __init__(self, args, gh: GitHub): - self.args = args - self.gh = gh - - def run(self) -> int: - """Executes the find-release-issue subcommand.""" - args = self.args - pr_ref = f"#{args.pr}" - - print(f"Searching for active release tracking issue containing PR {pr_ref}...") - try: - open_issues = self.gh.get_open_tracking_issues() - if not open_issues: - print("No open release tracking issues found.") - return 1 - - found_issue = None - for issue in open_issues: - issue_num = issue["number"] - body = self.gh.get_issue_body(issue_num) - backports = parse_backports(body) - - # Check if pr_ref is in backports - if any(item.pr_ref == pr_ref for item in backports): - if found_issue: - print( - f"Error: PR {pr_ref} found in multiple open release" - f" tracking issues: #{found_issue} and #{issue_num}" - ) - return 1 - found_issue = issue_num - - if found_issue: - print(f"Found PR {pr_ref} in tracking issue #{found_issue}") - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - try: - with open(github_output, "a") as f: - f.write(f"issue={found_issue}\n") - print(f"Wrote issue={found_issue} to GITHUB_OUTPUT") - except Exception as e: - print( - f"Failed to write to GITHUB_OUTPUT: {e}", - file=sys.stderr, - ) - return 1 - else: - print(f"issue={found_issue}") - return 0 - else: - print(f"PR {pr_ref} not found in any active release tracking issue.") - return 1 - - except Exception as e: - print(f"Error: {e}") - return 1 - - @classmethod - def add_parser(cls, subparsers): - """Adds parser for find-release-issue subcommand.""" - parser = subparsers.add_parser( - "find-release-issue", - help=( - "Find the release tracking issue containing a specific PR in" - " its backports." - ), - ) - parser.add_argument( - "pr", - type=int, - help="PR number to search for.", - ) - parser.set_defaults(command=cls.run_from_args) - - @classmethod - def run_from_args(cls, args): - """Instantiates and runs the command from parsed args.""" - gh = GitHub() - return cls(args, gh).run() diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index e670fc98df..8990ef5abf 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -2,6 +2,7 @@ import json import os +import re import tempfile from tools.private.release.release_issue import BackportTask @@ -316,6 +317,53 @@ def get_pr_info(self, pr_num: int) -> dict: ) return json.loads(output) if output else {} + def get_pr_comments(self, pr_num: int) -> list[dict]: + """Gets comments for a PR. + + Args: + pr_num: The PR number. + + Returns: + A list of comments. + """ + output = self._gh_pr( + "view", + str(pr_num), + "--json=comments", + ) + return json.loads(output).get("comments") or [] + + def resolve_pr_number(self, pr_ref: str) -> int: + """Resolves a PR reference (number, #number, URL) to a PR number. + + Args: + pr_ref: The PR reference string. + + Returns: + The resolved PR number. + + Raises: + ValueError: If the reference cannot be resolved. + """ + # 1. Try number (e.g. "123" or "#123") + clean_ref = pr_ref.lstrip("#") + if clean_ref.isdigit(): + return int(clean_ref) + + # 2. Try URL (starts with http) + if pr_ref.startswith("http"): + # Try to extract PR number from URL using regex + # Pattern matches: github.com//pull/ followed by /, ?, or EOF + pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" + match = re.search(pattern, pr_ref, re.IGNORECASE) + if match: + return int(match.group(1)) + raise ValueError( + f"URL is not for the configured repository ({self.repo}): {pr_ref}" + ) + + raise ValueError(f"Could not resolve PR reference: {pr_ref}") + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: """Posts a comment to a specific issue. diff --git a/tools/private/release/on_pr_merged.py b/tools/private/release/on_pr_merged.py new file mode 100644 index 0000000000..8c15193e61 --- /dev/null +++ b/tools/private/release/on_pr_merged.py @@ -0,0 +1,113 @@ +"""Subcommand to handle PR merge event by processing backports.""" + +import argparse +import re + +from tools.private.release.gh import GitHub +from tools.private.release.git import Git +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.release_issue import parse_backports + + +class OnPrMerged: + """Class to handle PR merge event.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the on-pr-merged subcommand.""" + args = self.args + pr_num = args.pr + pr_ref = f"#{pr_num}" + + print(f"Verifying PR {pr_ref} has backport comment...") + try: + comments = self.gh.get_pr_comments(pr_num) + has_comment = any( + re.match( + r"^\s*/backport(\s|$)", + comment.get("body", ""), + re.IGNORECASE, + ) + for comment in comments + ) + if not has_comment: + print(f"PR {pr_ref} does not have a /backport comment. Skipping.") + return 1 + except Exception as e: + print(f"Error checking PR comments: {e}") + return 1 + + print(f"Searching for active release tracking issue containing PR {pr_ref}...") + open_issues = self.gh.get_open_tracking_issues() + if not open_issues: + print("No open release tracking issues found.") + return 1 + + found_issue = None + for issue in open_issues: + issue_num = issue["number"] + body = self.gh.get_issue_body(issue_num) + backports = parse_backports(body) + + if any(item.pr_ref == pr_ref for item in backports): + if found_issue: + print( + f"Error: PR {pr_ref} found in multiple open release" + f" tracking issues: #{found_issue} and #{issue_num}" + ) + return 1 + found_issue = issue_num + + if not found_issue: + print(f"PR {pr_ref} not found in any active release tracking issue.") + return 1 + + print(f"Found PR {pr_ref} in tracking issue #{found_issue}") + + # Now run ProcessBackports for this issue + process_args = argparse.Namespace( + issue=found_issue, + remote=args.remote, + add=None, + triggering_comment=None, + dry_run=args.dry_run, + ) + print(f"Processing backports for issue #{found_issue}...") + return ProcessBackports(process_args, self.git, self.gh).run() + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for on-pr-merged subcommand.""" + parser = subparsers.add_parser( + "on-pr-merged", + help="Handle PR merge event by processing backports.", + ) + parser.add_argument( + "pr", + type=int, + help="PR number that was merged.", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 14b545cb71..6364c61b23 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -10,7 +10,7 @@ from tools.private.release.create_release_branch import CreateReleaseBranch from tools.private.release.create_release_issue import CreateReleaseIssue from tools.private.release.determine_next_version import DetermineNextVersion -from tools.private.release.find_release_issue import FindReleaseIssue +from tools.private.release.on_pr_merged import OnPrMerged from tools.private.release.prepare import Prepare from tools.private.release.process_backports import ProcessBackports from tools.private.release.promote_rc import PromoteRc @@ -23,7 +23,7 @@ CreateReleaseBranch, AddBackports, ProcessBackports, - FindReleaseIssue, + OnPrMerged, CreateRc, PromoteRc, ] From ad69a39b3c0ac162b3370632e00382ec4fd552b0 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 4 Jul 2026 03:21:51 +0000 Subject: [PATCH 3/6] docs: update RELEASING.md and support invalid PR refs in backports * Refine RELEASING.md backport documentation based on feedback. * Update process-backports GHA command to not abort on invalid PR refs, but add them to checklist with status=error-invalid-pr. * Support passing metadata (like status) when adding backports to issue body. --- RELEASING.md | 34 +++++++++++-- tests/tools/private/release/create_rc_test.py | 24 +++++++++ .../private/release/process_backports_test.py | 50 ++++++++++++++++++- tools/private/release/add_backports.py | 3 +- tools/private/release/create_rc.py | 5 +- tools/private/release/process_backports.py | 20 ++++++-- tools/private/release/release_issue.py | 33 ++++++++---- tools/private/release/utils.py | 19 ++----- 8 files changed, 151 insertions(+), 37 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 7846cef883..2298b4c8a3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,17 +79,41 @@ being accumulated for the next release, review the pending news entries in the To add backports to an active release, you can use one of the following methods: -### Method A: Manual Checklist Update +### Method A: Comment on the PR + +Comment `/backport` on the PR you wish to backport. This will automatically +add the PR to the active release's backports checklist. Once the PR is merged, +the backports will be automatically processed. + +> [!NOTE] +> Commenting `/backport` on an open PR will block further release publishing +> (like creating RCs or promoting) until the PR is merged or manually set to +> status=ignore in the checklist. + +### Method B: Comment on the Tracking Issue + +Comment `/add-backports [ ...]` (space or comma separated) on +the tracking issue. The `` can be a PR number (optionally prefixed with +`#`) or a PR URL (strictly for the configured repository). This will +automatically add the PRs to the checklist and trigger processing. + +### Method C: Manual Checklist Update 1. Manually add checklist items under the `## Backports` section of the Release Tracking Issue. The format must be: `- [ ] #` (e.g., `- [ ] #1234`). 2. When ready, comment `/process-backports` on the tracking issue to trigger processing. -### Method B: Comment Shortcut -1. Comment `/add-backports [ ...]` (space or comma - separated) on the tracking issue. This will automatically add the PRs to the - checklist and trigger processing. +### Method D: Release Tool CLI +You can use the release tool to add backports from your local checkout: +```shell +bazel run //tools/private/release -- add-backports [ ...] +``` +The `` can be: +* A PR number (e.g., `124` or `#124`) +* A PR URL (e.g., `https://github.com/bazel-contrib/rules_python/pull/124` + or `https://github.com/bazel-contrib/rules_python/pull/124/files`) +* Only URLs for the configured repository are accepted. ### Failure Behavior If a backport fails to process (e.g., due to cherry-pick conflicts): diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 06cdb77345..8e4ed101e4 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -189,6 +189,30 @@ def test_create_rc_gating_on_backports(self): self.mock_git.tag.assert_not_called() self.mock_git.push.assert_not_called() + def test_create_rc_not_blocked_by_ignored_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [ ] #124 | status=ignore +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + def test_create_rc_with_finished_backports(self): # Arrange args = MagicMock(issue=123, remote="my-remote") diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index 639171f59f..fe7bae256d 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -17,6 +17,9 @@ def setUp(self): "tools.private.release.process_backports.replace_version_next" ).start() self.addCleanup(patch.stopall) + self.mock_gh.resolve_pr_number.side_effect = lambda x: int( + x.lstrip("#").split("/")[-1] + ) def test_process_backports_no_pending(self): args = argparse.Namespace( @@ -254,7 +257,7 @@ def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetim issue=123, remote="origin", dry_run=False, - add=[124], + add=["https://github.com/bazel-contrib/rules_python/pull/124"], triggering_comment=None, ) self.mock_gh.get_issue_title.return_value = "Release 2.0.0" @@ -305,6 +308,51 @@ def mock_resolve(items): self.assertEqual(call2_args[0], 123) self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1]) + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_add_backports_marks_invalid(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, + remote="origin", + dry_run=False, + add=["124", "invalid", "125"], + triggering_comment=None, + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag Final + +## Backports +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "1234567890" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + def mock_resolve(items): + # Both 124 and 125 should be processed, 'invalid' should be ignored (it has error status) + for item in items: + if item.pr_ref in ("#124", "#125"): + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + # Should have updated body to add 124, 125, and invalid + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] + self.assertIn("- [ ] #124", call1_args[1]) + self.assertIn("- [ ] #125", call1_args[1]) + self.assertIn("- [ ] invalid | status=error-invalid-pr", call1_args[1]) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py index aac28fab11..54f287ce69 100644 --- a/tools/private/release/add_backports.py +++ b/tools/private/release/add_backports.py @@ -58,7 +58,8 @@ def run(self) -> int: ) try: body = self.gh.get_issue_body(issue_num) - body = add_backports_to_body(body, resolved_prs) + items_to_add = [{"ref": f"#{pr}"} for pr in resolved_prs] + body = add_backports_to_body(body, items_to_add) state = parse_checklist_state(body) rc_tags = state.get("rc_tags", {}) has_pending_rc = any( diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 0916377925..2e61e7d80a 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -84,7 +84,10 @@ def _run_internal(self) -> int: # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done backports = parse_backports(body) conflicting_or_pending = [ - b for b in backports if not b.checked or b.status != "done" + b + for b in backports + if (b.checked and b.status != "done") + or (not b.checked and b.status != "ignore") ] if conflicting_or_pending: print( diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 81dbe2bd5e..c8a936eab5 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -206,9 +206,23 @@ def _run_internal(self) -> int: body = self.gh.get_issue_body(args.issue) if args.add: - print(f"Adding backports {args.add} to tracking issue #{args.issue}...") + items_to_add = [] + for pr_ref in args.add: + try: + pr_num = self.gh.resolve_pr_number(pr_ref) + items_to_add.append({"ref": f"#{pr_num}"}) + except Exception as e: + print(f"Warning: PR ref '{pr_ref}' is invalid: {e}") + items_to_add.append( + { + "ref": pr_ref, + "metadata": {"status": "error-invalid-pr"}, + } + ) + + print(f"Adding backports {items_to_add} to tracking issue #{args.issue}...") try: - body = add_backports_to_body(body, args.add) + body = add_backports_to_body(body, items_to_add) state = parse_checklist_state(body) rc_tags = state.get("rc_tags", {}) has_pending_rc = any( @@ -356,7 +370,7 @@ def add_parser(cls, subparsers): parser.add_argument( "--add", type=parse_pr_list, - help="PR numbers (comma or space separated) to add before processing.", + help="PR references (numbers, #numbers, or URLs, comma/space separated) to add before processing.", ) parser.add_argument( "--triggering-comment", diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 2650b6607c..9870b9ba49 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -247,8 +247,14 @@ def parse_backports(body): return items -def add_backports_to_body(body: str, prs: list[int]) -> str: - """Adds new backport checklist items to the ## Backports section.""" +def add_backports_to_body(body: str, items: list[dict]) -> str: + """Adds new backport checklist items to the ## Backports section. + + Args: + body: The issue body. + items: A list of dicts, where each dict has a 'ref' key (str) and + optional 'metadata' key (dict). + """ body = body.replace("\r\n", "\n") # Find the Backports section pattern = r"(## Backports\n)(.*?)(?=\n##|\n---|\Z)" @@ -260,18 +266,23 @@ def add_backports_to_body(body: str, prs: list[int]) -> str: # Parse existing backports to avoid duplicates existing_items = parse_backports(body) - existing_prs = { - int(item.pr_ref.lstrip("#")) - for item in existing_items - if item.pr_ref.startswith("#") - } + existing_refs = {item.pr_ref for item in existing_items} new_lines = [] - for pr in prs: - if pr in existing_prs: - print(f"PR #{pr} is already in the backports list. Skipping.") + for item in items: + ref = item["ref"] + # Normalize numeric refs to #numeric + if not ref.startswith("#") and ref.isdigit(): + ref = f"#{ref}" + + if ref in existing_refs: + print(f"PR {ref} is already in the backports list. Skipping.") continue - new_lines.append(f"- [ ] #{pr}") + + metadata = item.get("metadata", {}) + new_lines.append( + format_metadata_line(checked=False, name=ref, metadata=metadata) + ) if not new_lines: return body diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index db7b96a378..cdd1b7724d 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -174,23 +174,12 @@ def replace_version_next(version): f.write(new_content) -def parse_pr_list(value: str) -> list[int]: - """Parses a comma or space separated list of PR numbers. +def parse_pr_list(value: str) -> list[str]: + """Parses a comma or space separated list of PR references. - PR numbers can optionally be prefixed with '#'. + PR references can be numbers (optionally prefixed with '#') or URLs. """ if not value: return [] # Split by space and/or comma - pr_strings = [p for p in re.split(r"[\s,]+", value.strip()) if p] - prs = [] - for pr_str in pr_strings: - clean_pr_str = pr_str.lstrip("#") - try: - prs.append(int(clean_pr_str)) - except ValueError as e: - raise argparse.ArgumentTypeError( - f"Invalid PR reference '{pr_str}'. Must be integer optionally" - f" prefixed with '#'." - ) from e - return prs + return [p for p in re.split(r"[\s,]+", value.strip()) if p] From 15029e536c0798ffb1e2e3034c2a2e442cefbc57 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 4 Jul 2026 03:23:04 +0000 Subject: [PATCH 4/6] fix: handle None body in comments and PR info safely Use `get("body") or ""` to guarantee a string fallback even if the body is explicitly None in API payloads, preventing TypeErrors in regex matching. --- tools/private/release/complete_prepare.py | 2 +- tools/private/release/on_pr_merged.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py index 7657738aad..8a60cbf0f3 100644 --- a/tools/private/release/complete_prepare.py +++ b/tools/private/release/complete_prepare.py @@ -25,7 +25,7 @@ def run(self) -> int: return 1 # Resolve issue number from PR body - pr_body = pr_info.get("body", "") + pr_body = pr_info.get("body") or "" match = re.search(r"Work towards #(\d+)", pr_body) if not match: match = re.search(r"#(\d+)", pr_body) diff --git a/tools/private/release/on_pr_merged.py b/tools/private/release/on_pr_merged.py index 8c15193e61..8d451580fb 100644 --- a/tools/private/release/on_pr_merged.py +++ b/tools/private/release/on_pr_merged.py @@ -29,7 +29,7 @@ def run(self) -> int: has_comment = any( re.match( r"^\s*/backport(\s|$)", - comment.get("body", ""), + comment.get("body") or "", re.IGNORECASE, ) for comment in comments From e8a50b4f946ae9912ca1359d95861c61a0e5a8bb Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 4 Jul 2026 03:23:28 +0000 Subject: [PATCH 5/6] fix: prevent duplicate backports in add_backports_to_body Update the set of existing PRs during the insertion loop to detect and skip duplicates in the same input list (including those that normalize to the same reference). --- .../private/release/release_issue_test.py | 31 +++++++++++++++++++ tools/private/release/release_issue.py | 1 + 2 files changed, 32 insertions(+) diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index edf5f603f0..cfc6da6ae4 100644 --- a/tests/tools/private/release/release_issue_test.py +++ b/tests/tools/private/release/release_issue_test.py @@ -1,6 +1,7 @@ import unittest from tools.private.release.release_issue import ( + add_backports_to_body, format_metadata_line, parse_metadata_line, ) @@ -65,6 +66,36 @@ def test_format_metadata_line(self): expected = "- [ ] Tag Final" self.assertEqual(format_metadata_line(False, "Tag Final", {}), expected) + def test_add_backports_to_body(self): + body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +- [ ] #123 | status=done +""" + items = [ + {"ref": "124"}, + {"ref": "#124"}, + {"ref": "125"}, + {"ref": "#123"}, + ] + updated_body = add_backports_to_body(body, items) + expected_body = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +- [ ] #123 | status=done +- [ ] #124 +- [ ] #125 +""" + self.assertEqual(updated_body.strip(), expected_body.strip()) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 9870b9ba49..359249d11c 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -278,6 +278,7 @@ def add_backports_to_body(body: str, items: list[dict]) -> str: if ref in existing_refs: print(f"PR {ref} is already in the backports list. Skipping.") continue + existing_refs.add(ref) metadata = item.get("metadata", {}) new_lines.append( From 6b1ef0b5a00d24b416c0eec00c638eb9a10609be Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 4 Jul 2026 03:23:56 +0000 Subject: [PATCH 6/6] doc: remove commitish from add-backports help text Update the parser help text for the 'prs' argument to reflect that commitish references are no longer supported. --- tools/private/release/add_backports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py index 54f287ce69..c25f509b9c 100644 --- a/tools/private/release/add_backports.py +++ b/tools/private/release/add_backports.py @@ -99,7 +99,7 @@ def add_parser(cls, subparsers): "prs", type=str, nargs="+", - help="PR references (number, #number, URL, or commitish) to add (positional, space-separated).", + help="PR references (numbers, #numbers, or URLs) to add (positional, space-separated).", ) parser.add_argument( "--issue",