From 1180b672f74c9d0c3fc4d516c6e1b82555d317e9 Mon Sep 17 00:00:00 2001 From: Andrew Hsu Date: Fri, 24 Apr 2026 07:33:02 -0700 Subject: [PATCH 1/4] gha: add AI-assisted fallback to /backport command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `git cherry-pick` in `pr_details.sh` fails with a conflict, the type-branch job now hands off to `anthropics/claude-code-action@v1` running the `.claude/skills/create-backport-branch/SKILL.md` skill. If the skill resolves the conflicts, it creates an `ai-backport-pr-*` branch on the bot fork and writes a Markdown report; the workflow then opens the backport PR (body = skill report verbatim) and tags it with the `ai-resolved-conflicts` label. If the skill aborts (modify/delete or architectural unknowns), behaviour matches today: no backport PR, fallback issue opened, ❌ reaction on the `/backport` comment. The clean-cherry-pick path is unchanged — when `pr_details` succeeds, the AI step is skipped and no Anthropic API call is made. The AI step runs inside `./fork` (reusing the bot-fork checkout from the existing workflow). `GH_REPO=$TARGET_FULL_REPO` is exported so the skill's `gh api "repos/{owner}/{repo}/..."` resolves to redpanda-data/redpanda rather than the bot fork that `origin` points at. `continue-on-error: true` on `pr_details` plus `always() && ...` on the failure-path steps keeps the existing failure handling intact for cases where both paths fail. Validated end-to-end on redpanda-data/test-migration. See DEVPROD-4091 for the test-migration staging and per-scenario verification. --- .github/workflows/backport-command.yml | 120 +++++++++++++++++- .../scripts/backport-command/create_pr.sh | 12 +- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/.github/workflows/backport-command.yml b/.github/workflows/backport-command.yml index d4fb91e5d176f..730c6ce9356bc 100644 --- a/.github/workflows/backport-command.yml +++ b/.github/workflows/backport-command.yml @@ -127,6 +127,7 @@ jobs: path: ./fork - name: Backport commits and get details if: needs.backport-type.outputs.commented_on == 'pr' + continue-on-error: true env: GITHUB_TOKEN: ${{ env.ACTIONS_BOT_TOKEN }} ORIG_TITLE: ${{ github.event.client_payload.github.payload.issue.title }} @@ -139,14 +140,105 @@ jobs: id: pr_details run: $SCRIPT_DIR/pr_details.sh shell: bash + - name: AI-assisted backport + if: needs.backport-type.outputs.commented_on == 'pr' && steps.pr_details.outcome == 'failure' + id: ai_backport + uses: anthropics/claude-code-action@v1 + env: + GITHUB_TOKEN: ${{ env.ACTIONS_BOT_TOKEN }} + GH_REPO: ${{ env.TARGET_FULL_REPO }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ env.ACTIONS_BOT_TOKEN }} + allowed_bots: "" + allowed_non_write_users: "" + show_full_output: true + track_progress: false + claude_args: > + --model opus + --max-turns 40 + --disallowed-tools "WebFetch,WebSearch" + --allowed-tools " + Bash(git *), + Bash(gh *), + Bash(date *), + Bash(grep *), + Bash(wc *), + Bash(echo *), + Bash(printf *), + Bash(cat *), + Bash(cd *), + Read, + Write, + Edit, + Glob, + Grep, + Agent" + prompt: | + The plain `git cherry-pick` in the prior step failed. Re-do the + backport using the skill at + ${{ github.workspace }}/.claude/skills/create-backport-branch/SKILL.md. + + Preflight (bash): + cd "${{ github.workspace }}/fork" + git cherry-pick --abort || true + export SKILL_REPORT_FILE="${{ github.workspace }}/.ai-backport-meta/report.md" + mkdir -p "$(dirname "$SKILL_REPORT_FILE")" + + Invoke the skill with: + $0 = ${{ env.BACKPORT_BRANCH }} (target — the release branch) + $1 = ${{ env.PR_NUMBER }} (pr-number) + + When the skill finishes successfully: + branch=$(git branch --show-current) + git push --set-upstream origin "$branch" + + If the skill aborts (modify/delete, architectural unknown, etc.), + exit 1. Do NOT push, do NOT write a report file. + - name: Load AI skill report + id: load_ai_handoff + if: steps.ai_backport.outcome == 'success' + run: | + report="${GITHUB_WORKSPACE}/.ai-backport-meta/report.md" + if [[ ! -s "$report" ]]; then + echo "::error::Skill report file missing or empty at $report (skill probably aborted)" + exit 1 + fi + branch=$(cd "${GITHUB_WORKSPACE}/fork" && git branch --list 'ai-backport-pr-*' --sort=-committerdate --format='%(refname:short)' | head -1) + if [[ -z "$branch" ]]; then + echo "::error::Report file written but no ai-backport-pr-* branch found" + exit 1 + fi + { + echo "AI_HEAD_BRANCH=$branch" + echo "AI_REPORT_FILE=$report" + } >> "$GITHUB_ENV" + echo "AI branch: $branch" + echo "AI report: $report ($(wc -l < "$report") lines)" + shell: bash + - name: Diagnostic after AI + if: always() && needs.backport-type.outputs.commented_on == 'pr' + run: | + cd "${GITHUB_WORKSPACE}/fork" 2>/dev/null || exit 0 + echo "== git log ==" + git log --oneline -20 || true + echo "== git status ==" + git status || true + echo "== current branch ==" + git branch --show-current || true + echo "== remotes ==" + git remote -v || true + echo "== AI_HEAD_BRANCH ==" + echo "${AI_HEAD_BRANCH:-}" + shell: bash - name: Create pull request - if: needs.backport-type.outputs.commented_on == 'pr' + if: needs.backport-type.outputs.commented_on == 'pr' && (steps.pr_details.outcome == 'success' || steps.load_ai_handoff.outcome == 'success') env: GITHUB_TOKEN: ${{ env.ACTIONS_BOT_TOKEN }} TARGET_MILESTONE: ${{ steps.create_milestone.outputs.milestone }} ORIG_TITLE: ${{ github.event.client_payload.github.payload.issue.title }} AUTHOR: ${{ github.event.client_payload.pull_request.user.login }} - HEAD_BRANCH: ${{ steps.pr_details.outputs.head_branch }} + HEAD_BRANCH: ${{ env.AI_HEAD_BRANCH || steps.pr_details.outputs.head_branch }} FIXING_ISSUE_URLS: ${{ steps.pr_details.outputs.fixing_issue_urls }} ORIG_PR_NUMBER: ${{ github.event.client_payload.pull_request.number }} ORIG_PR_URL: ${{ github.event.client_payload.pull_request.html_url }} @@ -154,7 +246,25 @@ jobs: id: create_pr run: $SCRIPT_DIR/create_pr.sh shell: bash + - name: Label AI-assisted PR + if: steps.load_ai_handoff.outcome == 'success' && steps.create_pr.outcome == 'success' + env: + GITHUB_TOKEN: ${{ env.ACTIONS_BOT_TOKEN }} + PR_HEAD: ${{ env.AI_HEAD_BRANCH }} + GIT_USER: ${{ steps.user.outputs.username }} + run: | + pr_num=$(gh pr list --repo "$TARGET_FULL_REPO" \ + --head "$GIT_USER:$PR_HEAD" --state open \ + --json number --jq '.[0].number') + if [[ -z "$pr_num" ]]; then + echo "::warning::Could not find backport PR with head=$GIT_USER:$PR_HEAD" + exit 0 + fi + gh pr edit "$pr_num" --repo "$TARGET_FULL_REPO" \ + --add-label ai-resolved-conflicts + shell: bash - name: Add reaction + if: steps.pr_details.outcome == 'success' || steps.load_ai_handoff.outcome == 'success' uses: peter-evans/create-or-update-comment@v4 with: token: ${{ env.ACTIONS_BOT_TOKEN }} @@ -162,22 +272,22 @@ jobs: comment-id: ${{ github.event.client_payload.github.payload.comment.id }} reactions: hooray - name: Failed reaction + if: always() && steps.pr_details.outcome == 'failure' && steps.load_ai_handoff.outcome != 'success' uses: peter-evans/create-or-update-comment@v4 - if: failure() with: token: ${{ env.ACTIONS_BOT_TOKEN }} repository: ${{ github.event.client_payload.github.payload.repository.full_name }} comment-id: ${{ github.event.client_payload.github.payload.comment.id }} reactions: "-1" - name: Post Error - if: failure() + if: always() && steps.pr_details.outcome == 'failure' && steps.load_ai_handoff.outcome != 'success' env: COMMENTED_ON: ${{ needs.backport-type.outputs.commented_on }} GITHUB_TOKEN: ${{ env.ACTIONS_BOT_TOKEN }} run: $SCRIPT_DIR/post_error.sh shell: bash - name: Create Issue On Error - if: failure() + if: always() && steps.pr_details.outcome == 'failure' && steps.load_ai_handoff.outcome != 'success' env: GITHUB_TOKEN: ${{ env.ACTIONS_BOT_TOKEN }} TARGET_MILESTONE: ${{ steps.create_milestone.outputs.milestone }} diff --git a/.github/workflows/scripts/backport-command/create_pr.sh b/.github/workflows/scripts/backport-command/create_pr.sh index 135039750b7c7..0da48f756195d 100755 --- a/.github/workflows/scripts/backport-command/create_pr.sh +++ b/.github/workflows/scripts/backport-command/create_pr.sh @@ -50,6 +50,15 @@ if [[ $FIXING_ISSUE_URLS != "" ]]; then backport_issue_urls=$(echo "$backport_issue_urls" | sed 's/.$//') fi +# If the AI path produced a skill report, use it verbatim as the PR body. +# Otherwise fall back to the plain one-line summary below. +if [[ -n "${AI_REPORT_FILE:-}" && -s "$AI_REPORT_FILE" ]]; then + body_args=(--body-file "$AI_REPORT_FILE") +else + body_args=(--body "Backport of PR $ORIG_ISSUE_URL +$backport_issue_urls") +fi + gh pr create --title "[$BACKPORT_BRANCH] $ORIG_TITLE" \ --base "$BACKPORT_BRANCH" \ --label "kind/backport" \ @@ -57,5 +66,4 @@ gh pr create --title "[$BACKPORT_BRANCH] $ORIG_TITLE" \ --repo "$TARGET_ORG/$TARGET_REPO" \ --reviewer "$AUTHOR" \ --milestone "$TARGET_MILESTONE" \ - --body "Backport of PR $ORIG_ISSUE_URL -$backport_issue_urls" + "${body_args[@]}" From d0de2c82e3ffa526e05efa79d9cefd0293e179d8 Mon Sep 17 00:00:00 2001 From: Andrew Hsu Date: Fri, 24 Apr 2026 07:46:08 -0700 Subject: [PATCH 2/4] gha: fix shfmt on backport create_pr.sh shfmt -i 2 -ci -s prefers unquoted variable references inside [[ ]] since bash doesn't word-split them there. --- .github/workflows/scripts/backport-command/create_pr.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scripts/backport-command/create_pr.sh b/.github/workflows/scripts/backport-command/create_pr.sh index 0da48f756195d..953e1ea95b476 100755 --- a/.github/workflows/scripts/backport-command/create_pr.sh +++ b/.github/workflows/scripts/backport-command/create_pr.sh @@ -52,7 +52,7 @@ fi # If the AI path produced a skill report, use it verbatim as the PR body. # Otherwise fall back to the plain one-line summary below. -if [[ -n "${AI_REPORT_FILE:-}" && -s "$AI_REPORT_FILE" ]]; then +if [[ -n ${AI_REPORT_FILE:-} && -s $AI_REPORT_FILE ]]; then body_args=(--body-file "$AI_REPORT_FILE") else body_args=(--body "Backport of PR $ORIG_ISSUE_URL From d8a7953c52f7c79292c429cb8ec80527df68f90c Mon Sep 17 00:00:00 2001 From: Andrew Hsu Date: Fri, 24 Apr 2026 08:25:25 -0700 Subject: [PATCH 3/4] gha: preserve Fixes: lines when AI path sets PR body The AI path uses --body-file $AI_REPORT_FILE, which replaces the PR body entirely. The earlier loop in create_pr.sh resolves/creates backport issues for each source-PR closing issue and collects them as 'Fixes: $url, ...'. Without those lines in the PR body, merging the AI-backport PR won't auto-close the backport issues this script just created. Append the Fixes: lines onto the AI report content in a temp file and use that as the --body-file input. Non-AI path is unchanged. --- .../workflows/scripts/backport-command/create_pr.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scripts/backport-command/create_pr.sh b/.github/workflows/scripts/backport-command/create_pr.sh index 953e1ea95b476..08a0639216021 100755 --- a/.github/workflows/scripts/backport-command/create_pr.sh +++ b/.github/workflows/scripts/backport-command/create_pr.sh @@ -50,10 +50,16 @@ if [[ $FIXING_ISSUE_URLS != "" ]]; then backport_issue_urls=$(echo "$backport_issue_urls" | sed 's/.$//') fi -# If the AI path produced a skill report, use it verbatim as the PR body. -# Otherwise fall back to the plain one-line summary below. +# If the AI path produced a skill report, use it as the PR body but still +# append the Fixes: lines so the backport PR auto-closes the backport issues +# this script just created. Otherwise fall back to the plain one-line summary. if [[ -n ${AI_REPORT_FILE:-} && -s $AI_REPORT_FILE ]]; then - body_args=(--body-file "$AI_REPORT_FILE") + combined_body=$(mktemp) + cat "$AI_REPORT_FILE" >"$combined_body" + if [[ -n $backport_issue_urls ]]; then + printf '\n\n%s\n' "$backport_issue_urls" >>"$combined_body" + fi + body_args=(--body-file "$combined_body") else body_args=(--body "Backport of PR $ORIG_ISSUE_URL $backport_issue_urls") From ca81efe5630f6c3a4ec2405c53b29f1e38ea8335 Mon Sep 17 00:00:00 2001 From: Andrew Hsu Date: Fri, 24 Apr 2026 08:28:23 -0700 Subject: [PATCH 4/4] gha: emit fixing_issue_urls early so AI path sees it pr_details.sh previously wrote fixing_issue_urls to $GITHUB_OUTPUT only after a successful cherry-pick. When the cherry-pick fails (which is exactly when the AI fallback kicks in), backport_failure exits 1 before that write lands, so steps.pr_details.outputs.fixing_issue_urls is empty for the AI-path create_pr step and the backport PR ends up without Fixes: links. Move the echo to right after fixing_issue_urls is computed (after the graphql call) so both the non-AI and AI paths see the same value. --- .github/workflows/scripts/backport-command/pr_details.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scripts/backport-command/pr_details.sh b/.github/workflows/scripts/backport-command/pr_details.sh index ee068af56bc79..4da4a66b95964 100755 --- a/.github/workflows/scripts/backport-command/pr_details.sh +++ b/.github/workflows/scripts/backport-command/pr_details.sh @@ -33,6 +33,11 @@ fixing_issue_urls=$(gh api graphql -f query='{ } }' --jq '.data.resource.closingIssuesReferences.nodes | map(.url) | join(" ")') +# Emit fixing_issue_urls now so downstream steps (e.g. the AI fallback's +# create_pr call) can read it even if the cherry-pick below fails and we +# exit via backport_failure. +echo "fixing_issue_urls=$fixing_issue_urls" >>$GITHUB_OUTPUT + suffix=$((RANDOM % 1000)) git config --global user.email "$GIT_EMAIL" git config --global user.name "$GIT_USER" @@ -65,4 +70,3 @@ fi git push --set-upstream origin "$head_branch" git remote rm upstream echo "head_branch=$head_branch" >>$GITHUB_OUTPUT -echo "fixing_issue_urls=$fixing_issue_urls" >>$GITHUB_OUTPUT