ci(autofix): add single-target scheduler

This commit is contained in:
yiliang114 2026-07-08 17:16:35 +08:00
parent 5d2bfbd21b
commit 295ffd9018
4 changed files with 201 additions and 38 deletions

View file

@ -8,22 +8,24 @@ name: 'Qwen Autofix'
# The lifecycle is asynchronous — a PR is opened in one run and its review is
# addressed in a later run once a reviewer has weighed in — so each scheduled
# tick runs only the phase(s) that make sense, decided by the `route` job:
# • every 4h → review phase (sweep the bot's open PRs)
# • every 12h (00/12 UTC) → also the issue phase (locate + fix one new bug)
# • every 10m → review phase; issue phase only if no PR needs work
# • issues:labeled → issue phase when ready label, state, and sender match
# • pull_request_review → review phase for submitted feedback on bot PRs
# • workflow_dispatch → force a phase, an issue, or a PR
#
# Every GitHub write (issue/PR comments, labels, branch push, PR create) goes
# through CI_DEV_BOT_PAT so the bot acts as a single identity, qwen-code-dev-bot.
# through CI_DEV_BOT_PAT so the bot acts as the configured autofix identity.
# PAT label writes can emit issues:labeled events; the route guards below make
# those runs exit unless the label, issue state, and ready label all match.
on:
issues:
types:
- 'labeled'
pull_request_review:
types:
- 'submitted'
schedule:
- cron: '0 0,12 * * *' # Issue + review every 12h; must match route SCHEDULE check
- cron: '0 4,8,16,20 * * *' # Review only between issue runs
- cron: '*/10 * * * *' # Review first; issue fallback only when no PR needs work
workflow_dispatch:
inputs:
phase:
@ -60,7 +62,7 @@ permissions:
env:
# Identity of the autofix bot and the branches it owns. Only its own in-repo
# PRs are ever eligible for the review phase — never a fork or another branch.
AUTOFIX_BOT: 'qwen-code-dev-bot'
AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
BRANCH_PREFIX: 'autofix/issue-'
# The automated Qwen PR reviewer posts as this account; its review counts as
# actionable feedback even though it is not a human collaborator.
@ -71,7 +73,9 @@ env:
TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]'
# Hard cap on automated review-address rounds per PR. After this the bot stops
# and leaves the PR for a human.
MAX_ROUNDS: '3'
MAX_ROUNDS: '5'
# Do not claim more issues when too many existing autofix PRs are still open.
MAX_OPEN_AUTOFIX_PRS: '5'
jobs:
# ---------------------------------------------------------------------------
@ -110,6 +114,8 @@ jobs:
REPO: '${{ github.repository }}'
SENDER_LOGIN: '${{ github.event.sender.login }}'
SCHEDULE: '${{ github.event.schedule }}'
PR_AUTHOR: '${{ github.event.pull_request.user.login }}'
PR_NUMBER_EVENT: '${{ github.event.pull_request.number }}'
run: |-
DO_ISSUE=false
DO_REVIEW=false
@ -137,10 +143,21 @@ jobs:
if [[ "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ]]; then
DO_REVIEW=true
fi
# Must match the issue-phase cron string on the schedule trigger.
if [[ "${EVENT_NAME}" == 'schedule' && "${SCHEDULE}" == '0 0,12 * * *' ]]; then
# Scheduled runs scan review PRs first; issue-autofix runs only
# when review-scan reports no target.
if [[ "${EVENT_NAME}" == 'schedule' ]]; then
DO_ISSUE=true
fi
if [[ "${EVENT_NAME}" == 'pull_request_review' ]]; then
DO_ISSUE=false
if [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then
DO_REVIEW=true
ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")"
echo "🧭 review event on bot PR #${PR_NUMBER_EVENT} → review phase"
else
echo "🧭 review event ignored: PR author '${PR_AUTHOR:-n/a}' is not ${AUTOFIX_BOT}"
fi
fi
if [[ "${EVENT_NAME}" == 'issues' ]]; then
DO_REVIEW=false
label_is_trigger=false
@ -190,15 +207,19 @@ jobs:
echo "dry_run=${DRY_RUN}" >> "${GITHUB_OUTPUT}"
echo "issue_number=${ROUTE_ISSUE}" >> "${GITHUB_OUTPUT}"
echo "pr_number=${ROUTE_PR}" >> "${GITHUB_OUTPUT}"
echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}"
echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' pr='#${PR_NUMBER_EVENT:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}"
# ===========================================================================
# ISSUE PHASE — locate one maintainer-ready issue, fix it, open a PR.
# ===========================================================================
issue-autofix:
needs: 'route'
needs: ['route', 'review-scan']
if: |-
${{ needs.route.outputs.do_issue == 'true' }}
${{
always() &&
needs.route.outputs.do_issue == 'true' &&
(github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true'))
}}
runs-on: 'ubuntu-latest'
timeout-minutes: 180
concurrency:
@ -319,6 +340,7 @@ jobs:
FORCED_ISSUE: '${{ needs.route.outputs.issue_number || github.event.issue.number }}'
run: |-
mkdir -p "${WORKDIR}"
OPEN_AUTOFIX_PR_COUNT=0
if [[ -n "${FORCED_ISSUE}" ]]; then
echo "🎯 Forced issue #${FORCED_ISSUE}"
@ -354,34 +376,51 @@ jobs:
fi
fi
else
echo "🔍 Ready-for-agent issues (newest first)..."
if ! gh issue list --repo "${REPO}" \
if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then
echo "::warning::Open autofix PR scan failed; proceeding without WIP-cap enforcement."
else
OPEN_AUTOFIX_PR_COUNT="$(jq --arg p "${BRANCH_PREFIX}" \
'[.[] | select((.isCrossRepository != true) and ((.headRefName // "") | startswith($p)))] | length' \
"${WORKDIR}/open-autofix-prs.json")"
fi
if [[ "${OPEN_AUTOFIX_PR_COUNT}" -ge "${MAX_OPEN_AUTOFIX_PRS}" ]]; then
echo "⏭️ ${OPEN_AUTOFIX_PR_COUNT} open autofix PR(s) already exist; WIP limit is ${MAX_OPEN_AUTOFIX_PRS}; skipping issue fallback."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
echo "🔍 Ready-for-agent issues (newest first)..."
if ! gh issue list --repo "${REPO}" \
--search "is:open is:issue label:${READY_FOR_AGENT_LABEL} label:${AUTOFIX_APPROVED_LABEL} ${AUTOFIX_ISSUE_EXCLUDES}" \
--limit 30 --json number,title,body,labels,createdAt,url \
> "${WORKDIR}/scan.json"; then
echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \
"${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then
echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list."
echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \
"${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then
echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
fi
fi
fi
fi
COUNT="$(jq length "${WORKDIR}/candidates.json")"
if [[ "${COUNT}" -gt 0 ]]; then
if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName > "${WORKDIR}/open-autofix-prs.json"; then
if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then
echo " Reusing open autofix PR scan for duplicate-PR annotation."
elif ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then
echo "::warning::Open autofix PR scan failed; candidates will proceed without duplicate-PR annotation."
else
fi
if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then
if ! jq -c --arg p "${BRANCH_PREFIX}" --slurpfile prs "${WORKDIR}/open-autofix-prs.json" '
($prs[0] // []) as $prs
| map(
($p + (.number | tostring)) as $branch
| (
first($prs[] | select((.headRefName // "") == $branch) | {
first($prs[] | select((.isCrossRepository != true) and ((.headRefName // "") == $branch)) | {
number,
headRefName
}) // null
@ -752,8 +791,8 @@ jobs:
if: |-
${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }}
env:
# CI_DEV_BOT_PAT (the qwen-code-dev-bot PAT) opens the PR as
# qwen-code-dev-bot. This is required: the default GITHUB_TOKEN is
# CI_DEV_BOT_PAT opens the PR as the configured autofix bot. This is
# required: the default GITHUB_TOKEN is
# blocked from creating PRs ("GitHub Actions is not permitted to
# create or approve pull requests"), and PRs it does create do not
# trigger CI. The bot PAT clears both problems.
@ -761,7 +800,7 @@ jobs:
ISSUE: '${{ steps.decision.outputs.go_issue }}'
run: |-
if [[ -z "${GITHUB_TOKEN}" ]]; then
echo '::error::CI_DEV_BOT_PAT is required to publish the PR as qwen-code-dev-bot.'
echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.'
exit 1
fi
api_error_file="$(mktemp)"
@ -849,8 +888,8 @@ jobs:
fi
# ===========================================================================
# REVIEW PHASE (scan) — find every autofix PR with new, unaddressed feedback
# (or a base conflict) and emit them as a matrix. Cheap: GitHub API only.
# REVIEW PHASE (scan) — find one autofix PR with new, unaddressed feedback
# (or a base conflict) and emit it as a matrix. Cheap: GitHub API only.
# ===========================================================================
review-scan:
needs: 'route'
@ -876,22 +915,24 @@ jobs:
# A forced PR must still pass these checks.
if [[ -n "${FORCED_PR}" ]]; then
META="$(gh pr view "${FORCED_PR}" --repo "${REPO}" \
--json number,state,author,headRefName 2> /dev/null || echo '{}')"
--json number,state,author,headRefName,isCrossRepository 2> /dev/null || echo '{}')"
OK="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg p "${BRANCH_PREFIX}" \
'(((.state // "") == "OPEN")
and ((.author.login // "") == $ab)
and ((.isCrossRepository // true) == false)
and ((.headRefName // "") | startswith($p)))' <<< "${META}")"
if [[ "${OK}" != "true" ]]; then
echo "❌ #${FORCED_PR} is not an open autofix PR owned by ${AUTOFIX_BOT}"
echo "❌ #${FORCED_PR} is not an open in-repo autofix PR owned by ${AUTOFIX_BOT}"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
CANDIDATES="${FORCED_PR}"
else
gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName > "${WORKDIR}/bot-prs.json"
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/bot-prs.json"
CANDIDATES="$(jq -r --arg p "${BRANCH_PREFIX}" \
'.[] | select(.headRefName | startswith($p)) | .number' \
'.[] | select((.isCrossRepository != true) and ((.headRefName // "") | startswith($p))) | .number' \
"${WORKDIR}/bot-prs.json")"
fi
@ -900,6 +941,18 @@ jobs:
BRANCH="$(gh pr view "${PR}" --repo "${REPO}" --json headRefName --jq '.headRefName')"
ISSUE="${BRANCH#"${BRANCH_PREFIX}"}"
HEAD_SHA="$(gh api "repos/${REPO}/pulls/${PR}" --jq '.head.sha')"
CHECKS_JSON="$(gh pr view "${PR}" --repo "${REPO}" \
--json statusCheckRollup --jq '.statusCheckRollup // []' 2> /dev/null || echo '[]')"
HAS_PENDING_CHECKS="$(jq -r '
[ .[]
| select((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) ]
| length > 0
' <<< "${CHECKS_JSON}")"
if [[ "${HAS_PENDING_CHECKS}" == "true" ]]; then
echo "⏳ #${PR}: PR has pending checks; skipping until the current verification finishes"
continue
fi
# Push watermark: the PR's last push. Feedback older than this was in
# front of the agent on a previous round.
@ -960,6 +1013,7 @@ jobs:
--arg round "${ROUND}" --arg wm "${EFF_WM}" \
'. + [{pr: $pr, branch: $branch, issue: $issue, round: $round, watermark: $wm}]' \
<<< "${TARGETS}")"
break # one PR per scheduled scan
done
COUNT="$(jq 'length' <<< "${TARGETS}")"

View file

@ -98,7 +98,7 @@ blocked, write `<workdir>/failure.md` and do not commit.
Inputs: `--pr`, `--issue`, `<workdir>/feedback.md`, `--conflict`, and `--base`.
The workflow already checked out `autofix/issue-<issue>`. Stay on that branch.
The workflow already checked out the PR's head branch. Stay on that branch.
Read `git diff origin/<base>...HEAD` first, then `<workdir>/feedback.md`.
Classify every feedback point:

View file

@ -0,0 +1,50 @@
# Autofix Single Target Scheduler Implementation Plan
**Goal:** Make the Qwen Autofix workflow run every 10 minutes, process at most one actionable autofix PR per scan, react once to submitted reviews on bot PRs, and fall back to one existing approved issue only when no PR needs work.
**Architecture:** Keep the existing `qwen-autofix.yml` lifecycle and agent modes. Tighten the route and scan jobs so scheduled runs always scan review PRs first, issue work only proceeds when review scan has no target, and PR handling is still locked per PR.
**Tech Stack:** GitHub Actions YAML, `gh` CLI, `jq`, Vitest string guards in `scripts/tests/qwen-autofix-workflow.test.js`.
### Task 1: Add Workflow Guard Tests
**Files:**
- Modify: `scripts/tests/qwen-autofix-workflow.test.js`
**Steps:**
1. Add tests for `AUTOFIX_BOT` using `vars.AUTOFIX_BOT_LOGIN`.
2. Add tests for `*/10 * * * *` schedule and no 4-hour schedule.
3. Add tests that submitted review events route only for bot PRs.
4. Add tests that review scan emits at most one target and skips PRs with pending checks.
5. Add tests that issue phase waits for review scan to have no target and respects a WIP PR cap.
6. Run `npx vitest run scripts/tests/qwen-autofix-workflow.test.js` and confirm failure.
### Task 2: Update Workflow Routing and Scan
**Files:**
- Modify: `.github/workflows/qwen-autofix.yml`
**Steps:**
1. Change scheduled cron to 10 minutes.
2. Change `AUTOFIX_BOT` to use `vars.AUTOFIX_BOT_LOGIN`.
3. Raise `MAX_ROUNDS`.
4. Add `MAX_OPEN_AUTOFIX_PRS`.
5. Add a low-frequency `pull_request_review: submitted` trigger for bot PRs.
6. Make `issue-autofix` depend on `review-scan` for scheduled review-first fallback.
7. Make review scan pick one target and skip PRs with pending checks.
8. Gate issue candidate scan when review scan has a target or open autofix PR count exceeds the WIP cap.
### Task 3: Verify
**Files:**
- Test: `scripts/tests/qwen-autofix-workflow.test.js`
**Steps:**
1. Run `npx vitest run scripts/tests/qwen-autofix-workflow.test.js`.
2. Inspect the diff for unrelated changes.

View file

@ -32,6 +32,11 @@ const routeStep =
workflow.match(
/- name: 'Decide phases'[\s\S]*?(?=\n[ ]{2}# ==========)/,
)?.[0] ?? '';
const reviewScanJob =
workflow.match(/\n {2}review-scan:[\s\S]*?(?=\n[ ]{2}# ==========)/)?.[0] ?? '';
const issueAutofixJob =
workflow.match(/\n {2}issue-autofix:[\s\S]*?(?=\n[ ]{2}# ==========)/)?.[0] ??
'';
const publishPrStep =
workflow.match(
/- name: 'Publish PR'[\s\S]*?(?=\n[ ]{6}- name: 'Withdraw claim on failure')/,
@ -191,6 +196,63 @@ describe('qwen-autofix workflow', () => {
expect(workflow).toContain('.[0:10] | map(. + {autofixTier: 1})');
});
it('runs scheduled autofix as a 10-minute single-target worker', () => {
expect(workflow).toContain("cron: '*/10 * * * *'");
expect(workflow).not.toContain("cron: '0 0,12 * * *'");
expect(workflow).not.toContain("cron: '0 4,8,16,20 * * *'");
expect(workflow).toContain(
"pull_request_review:\n types:\n - 'submitted'",
);
expect(workflow).toContain(
'AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || \'qwen-code-dev-bot\' }}"',
);
expect(workflow).toContain("MAX_ROUNDS: '5'");
expect(workflow).toContain("MAX_OPEN_AUTOFIX_PRS: '5'");
expect(reviewScanJob).toContain('isCrossRepository');
expect(reviewScanJob).toContain('not an open in-repo autofix PR');
expect(reviewScanJob).toContain('.isCrossRepository != true');
expect(reviewScanJob).toContain('break # one PR per scheduled scan');
expect(reviewScanJob).toContain('statusCheckRollup');
expect(reviewScanJob).toContain('HAS_PENDING_CHECKS');
expect(reviewScanJob).toContain('.status // .state // ""');
expect(reviewScanJob).toContain('.workflowName // ""');
expect(reviewScanJob).toContain('startswith("review-address")');
expect(reviewScanJob).toContain('echo "targets=[]" >> "${GITHUB_OUTPUT}"');
expect(reviewScanJob).toContain(
'PR has pending checks; skipping until the current verification finishes',
);
});
it('falls back to existing issue backlog only when review has no target', () => {
expect(issueAutofixJob).toContain("needs: ['route', 'review-scan']");
expect(issueAutofixJob).toContain('always()');
expect(issueAutofixJob).toContain("needs.review-scan.result == 'success'");
expect(issueAutofixJob).toContain(
"github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')",
);
expect(findCandidateIssuesStep).toContain('OPEN_AUTOFIX_PR_COUNT');
expect(findCandidateIssuesStep).toContain('MAX_OPEN_AUTOFIX_PRS');
expect(findCandidateIssuesStep).toContain('isCrossRepository');
expect(findCandidateIssuesStep).toContain(
'open autofix PR(s) already exist; WIP limit is ${MAX_OPEN_AUTOFIX_PRS}',
);
});
it('routes submitted review events only for bot PRs', () => {
expect(routeStep).toContain('PR_AUTHOR');
expect(routeStep).toContain('PR_NUMBER_EVENT');
expect(routeStep).toContain(
'if [[ "${EVENT_NAME}" == \'pull_request_review\' ]]; then',
);
expect(routeStep).toContain('if [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" ]]');
expect(routeStep).toContain(
'ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")',
);
expect(routeStep).toContain(
"review event ignored: PR author '${PR_AUTHOR:-n/a}' is not ${AUTOFIX_BOT}",
);
});
it('keeps label-triggered issue routing guarded and diagnosable', () => {
expect(workflow).toContain("issues:\n types:\n - 'labeled'");
expect(workflow).toContain(
@ -273,9 +335,6 @@ describe('qwen-autofix workflow', () => {
expect(workflow).not.toContain(
"pull_request_review_comment:\n types:\n - 'created'",
);
expect(workflow).not.toContain(
"pull_request_review:\n types:\n - 'submitted'",
);
expect(workflow).not.toContain(
"COMMENT_BODY: '${{ github.event.comment.body }}'",
);
@ -342,7 +401,7 @@ describe('qwen-autofix workflow', () => {
'($p + (.number | tostring)) as $branch',
);
expect(findCandidateIssuesStep).toContain(
'first($prs[] | select((.headRefName // "") == $branch)',
'first($prs[] | select((.isCrossRepository != true) and ((.headRefName // "") == $branch))',
);
expect(findCandidateIssuesStep).toContain('existingAutofixPr');
expect(findCandidateIssuesStep).toContain('annotated-candidates.json');