Qwen Autofix #280
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 'Qwen Autofix' | |
| # One workflow for the whole autonomous-fix lifecycle: | |
| # | |
| # issue → locate → fix → open PR (issue phase) | |
| # open PR → review → triage → fix → push (review phase) | |
| # | |
| # 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 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 | |
| # • pull_request:labeled → maintainer applies autofix/takeover → the loop | |
| # manages that PR (human-authored included, and | |
| # maintainer FORKS too: the fork's author must | |
| # hold write+ live and the PR must allow | |
| # maintainer edits — the bot then fetches/pushes | |
| # the fork branch directly; org-owned forks | |
| # cannot enable allow-edits → adoption instead); | |
| # unlabeled releases it. autofix/skip opts any PR | |
| # out everywhere and wins over takeover. Labels | |
| # need GitHub triage+, so the permission gate is | |
| # GitHub's own. The bot's OWN fork PRs (author == | |
| # the autofix bot, e.g. its codex flow) are auto- | |
| # managed WITHOUT a label when allow-edits is on — | |
| # they are the bot's own generated work, trust- | |
| # equal to an in-repo bot PR; autofix/skip still | |
| # opts them out. | |
| # • issue_comment → '@qwen-code /takeover' (apply the label) and | |
| # '@qwen-code /takeover stop' (remove it) — sugar | |
| # for people without label access: the PR author, | |
| # or write+ collaborators. Exact-match constants, | |
| # and the ONLY side effect is the label toggle; | |
| # engagement/release still happen exclusively via | |
| # the label events, so manual labeling and the | |
| # commands are the same single mechanism. | |
| # • 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 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' | |
| - 'assigned' | |
| pull_request_review: | |
| types: | |
| - 'submitted' | |
| pull_request: | |
| types: | |
| - 'labeled' | |
| - 'unlabeled' | |
| issue_comment: | |
| types: | |
| - 'created' | |
| schedule: | |
| - cron: '*/10 * * * *' # Review first; issue fallback only when no PR needs work | |
| workflow_dispatch: | |
| inputs: | |
| phase: | |
| description: 'Which phase(s) to run' | |
| required: false | |
| default: 'auto' | |
| type: 'choice' | |
| options: | |
| - 'auto' # review always; issue on schedule or ready-for-agent label | |
| - 'issue' # locate + fix one bug only | |
| - 'review' # address review on open PRs only | |
| - 'both' # issue and review | |
| issue_number: | |
| description: 'Force a specific issue number (implies the issue phase)' | |
| required: false | |
| type: 'string' | |
| pr_number: | |
| description: 'Force a specific bot PR number (implies the review phase)' | |
| required: false | |
| type: 'string' | |
| dry_run: | |
| description: 'Assess/develop/address and verify, but do not claim, push, or comment' | |
| required: false | |
| type: 'boolean' | |
| default: false | |
| defaults: | |
| run: | |
| shell: 'bash' | |
| permissions: | |
| contents: 'read' | |
| env: | |
| # Identity of the autofix bot. All open in-repo PRs authored by this bot are | |
| # eligible for the review phase — not limited to autofix/issue-* branches. | |
| AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}" | |
| # Branch-name prefix used by the issue phase when creating PRs. Also used | |
| # for duplicate-PR detection and issue-number extraction from branch names. | |
| 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. | |
| REVIEW_BOT: 'qwen-code-ci-bot' | |
| # Human reviews/comments only count when the author is a real maintainer. This | |
| # is the prompt-injection trust gate: feedback from anyone else is ignored so a | |
| # hostile commenter cannot steer the agent. | |
| 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. Raised from 5: across the last 40 bot PRs | |
| # only 3 ever reached the cap and all 3 merged AT it (one having spent two of | |
| # its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling | |
| # was near enough to bind on a bad day without any headroom for one. The cap | |
| # exists to stop an unproductive LOOP, not to ration ordinary iteration — | |
| # a genuinely stuck PR still stops, just later. | |
| MAX_ROUNDS: '10' | |
| # Suggestions may improve a PR, but continuing to implement them after ten | |
| # change-producing rounds expands the diff and creates fresh review churn. | |
| # From round 11 onward, only Critical findings, formally requested changes, | |
| # failed checks, and base conflicts may drive code changes; lower-severity | |
| # feedback is recorded and left open. | |
| CRITICAL_ONLY_AFTER_ROUND: '10' | |
| # Per-author tail budget inside Critical-only mode. An account is an | |
| # ACCOUNTABILITY unit, not a throttle: a human login can host an automated | |
| # reviewer loop with the exact regeneration property the review bot has | |
| # (feedback re-generated after every push, at zero marginal cost). So the | |
| # brake keys on measured regeneration, not identity: every source gets a | |
| # bounded number of untagged feedback batches per counting window once | |
| # Critical-only engages — the review bot's budget is zero (all deferred), | |
| # a human's is this many CONSUMED batches. Past it, continuing requires | |
| # one conscious act (**[Critical]**, a Request changes review, or /retry), | |
| # which is precisely what separates intent from automation. | |
| CRITICAL_ONLY_HUMAN_BATCHES: '2' | |
| # An auth/access model error (401/402/403, "no access"/"does not exist") | |
| # never self-heals - only a maintainer can fix the key - and every retry | |
| # costs an agent run AND a PR comment. Cap those attempts far below | |
| # MAX_ROUNDS so the actionable "check the model key" message lands in an | |
| # hour instead of a day. Transient (429/5xx) errors keep the full budget. | |
| API_AUTH_MAX_ROUNDS: '3' | |
| # Checks the "wait for checks to settle" gate does NOT wait for. The gate | |
| # exists so a FAILED check can be read as feedback, which is why build/test/ | |
| # lint are still waited for. `review-pr` is the LLM code review: its output | |
| # is a REVIEW, delivered by its own real-time pull_request_review trigger and | |
| # counted by the review path — the check conclusion carries nothing the loop | |
| # acts on. Blocking on it bought nothing and cost a median 49 minutes per | |
| # round (p90 123, max 158, over 32 completed runs), during which the PR was | |
| # invisible to the scan even when it already had unaddressed feedback. | |
| # Names must match job ids in qwen-code-pr-review.yml; a test pins that. | |
| NON_BLOCKING_CHECKS: '["review-pr"]' | |
| # Failed-check annotation patterns that mean the INFRASTRUCTURE died, not the | |
| # code — a self-hosted runner losing the server, the disk filling, a runner | |
| # shutdown, or a git fetch/clone dying mid-transfer. Such a check is red for a | |
| # reason unrelated to the PR and clears on a re-run (observed: #7490's E2E | |
| # "runner lost communication"; #6506's checkout "RPC failed; curl 92" / | |
| # "fetch-pack: invalid index-pack output" — both green on the rerun). The scan | |
| # auto-reruns those failed jobs ONCE, guarded by run_attempt so a persistent | |
| # infra problem cannot loop. Deliberately conservative — only unambiguous | |
| # machine/transport failures, never a bare test-level timeout, which could be | |
| # a real regression (a co-present timeout does not block a match — one | |
| # matching line classifies the run). Case-insensitive, vs the annotations. | |
| INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed' | |
| # Upper bound on review targets emitted per scan (fan-out defense-in-depth; | |
| # excess is logged and deferred to the next scan). | |
| MAX_TARGETS_PER_SCAN: '10' | |
| # Upper bound on candidates INSPECTED per scan: idle candidates consume | |
| # serial API calls even when they emit nothing, and takeover widens the | |
| # candidate pool. Candidates are inspected NEWEST-first; past the budget | |
| # the oldest tail defers — old quiet PRs are the least likely to hold new | |
| # feedback, and a deferred PR with a live conflict is still picked up by | |
| # the shepherd's conflict lever. | |
| MAX_CANDIDATE_INSPECTIONS: '60' | |
| # Maintainer-facing engagement labels (applying labels requires GitHub | |
| # triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR — | |
| # including a human-authored one — into the loop; SKIP opts any PR out | |
| # everywhere, and wins when both are present. | |
| TAKEOVER_LABEL: 'autofix/takeover' | |
| SKIP_LABEL: 'autofix/skip' | |
| # Comment-command sugar over TAKEOVER_LABEL ('<cmd>' applies it, '<cmd> | |
| # stop' removes it). Matched EXACTLY against the trimmed comment body. | |
| TAKEOVER_COMMAND: '@qwen-code /takeover' | |
| # Re-arm sugar. Recovering a stranded PR previously meant DELETING the | |
| # bot's autofix-eval marker comment by hand (undiscoverable, destructive, | |
| # and it erases the audit trail). This command instead posts an | |
| # 'autofix-rearm' marker that supersedes the earlier evaluation markers: | |
| # the scan re-reads the feedback from scratch and the round counter resets. | |
| RETRY_COMMAND: '@qwen-code /retry' | |
| # Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely | |
| # need dozens of feedback rounds — that is the point of takeover — so the | |
| # unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays | |
| # (a bot↔review-bot ping-pong is still bounded), it is just sized for | |
| # explicitly delegated work; removing the label restores the strict cap, | |
| # and re-engaging opens a fresh counting window (see REARM_KEY below). | |
| TAKEOVER_MAX_ROUNDS: '100' | |
| # Consecutive-failure sub-cap, distinct from the total round cap above. The | |
| # total cap bounds how many PRODUCTIVE rounds a PR may take; this bounds how | |
| # many rounds may fail IN A ROW with nothing pushed. Under takeover a PR gets | |
| # up to 100 rounds, but a PR that fails to push this many times running is not | |
| # iterating, it is stuck — a too-large / fast-conflicting PR whose fix keeps | |
| # timing out or failing the gate. Retrying at the same budget will not fix | |
| # that; a human has to rebase or split it. Any pushed round OR a legitimate | |
| # "no changes needed" no-op resets the streak, so this only ever fires on an | |
| # unbroken run of failures. Observed on #6723: 7 straight failed rounds (3 | |
| # timeouts, 4 gate rejections) over 8 hours, heading for 100. | |
| CONSECUTIVE_FAILURE_CAP: '5' | |
| # Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for | |
| # the failure shape it cannot see: timeouts INTERLEAVED with successful | |
| # rounds. A success resets the consecutive streak, but it does not make the | |
| # next timeout any cheaper — each one burns a full agent budget (~50m of | |
| # runner time) and pushes nothing. Observed on #7929: three timeouts with | |
| # pushed rounds in between, so the consecutive cap never fired and the PR | |
| # kept walking into the same wall; #7846 the same, twice. Counted over the | |
| # current counting window (window-scoped like every other census), so a | |
| # re-arm clears it along with the round counter. | |
| TIMEOUT_WINDOW_CAP: '3' | |
| # Do not claim more issues when too many existing autofix PRs are still open. | |
| MAX_OPEN_AUTOFIX_PRS: '5' | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # Router: fork the run into phases by schedule/dispatch input. | |
| # --------------------------------------------------------------------------- | |
| route: | |
| # The issue_comment clause is a cheap expression-level prefilter: the | |
| # overwhelming majority of comments never start a job at all. The real | |
| # gates (exact body match, sender authorization) live in 'Decide phases'. | |
| # Nuance: a body with LEADING whitespace dies here even though the decide | |
| # branch would trim it — fail closed, command must start the comment. | |
| # Both commands are prefiltered here (/takeover toggles the label, /retry | |
| # re-arms a stranded PR); everything else never starts a job. | |
| if: |- | |
| ${{ github.repository == 'QwenLM/qwen-code' && (github.event_name != 'issue_comment' || (github.event.issue.pull_request && (startsWith(github.event.comment.body, '@qwen-code /takeover') || startsWith(github.event.comment.body, '@qwen-code /retry')))) && (github.event_name != 'pull_request' || github.event.label.name == 'autofix/takeover') }} | |
| runs-on: 'ubuntu-latest' | |
| timeout-minutes: 5 | |
| concurrency: | |
| # Concurrency is keyed by TARGET, not shared and not fully unique: | |
| # • cron ticks share one group (a newer tick supersedes a queued one) | |
| # • review events coalesce PER PR (two reviews on the same PR seconds | |
| # apart route once — the old shared group's one useful side effect, | |
| # kept, without letting events on OTHER PRs cancel this one) | |
| # • issue events coalesce PER issue | |
| # • dispatches are unique per run and are never cancelled | |
| # The old single shared cancel-in-progress group let ANY newer event kill | |
| # pending full scans while route jobs sat queued behind runner backlog — | |
| # observed as hours of scan starvation during review-event storms. | |
| # Five cases: schedule → one shared cron group (newer tick supersedes); | |
| # pull_request_review → per-PR, but ONLY when the review payload | |
| # already looks trusted (the group is entered before any step runs, so | |
| # an arbitrary commenter's review would otherwise cancel a queued | |
| # legitimate route and then die in 'Decide phases' — untrusted payloads | |
| # get a run-unique group and still face the real permission gate | |
| # inside; the association literal mirrors TRUSTED_ASSOC and the login | |
| # mirrors REVIEW_BOT); pull_request label events → per-PR (GitHub only | |
| # lets triage+ apply labels, so the whole event class is trusted — | |
| # in their OWN per-PR group (label-{N}), distinct from the review | |
| # group so a simultaneous review and label toggle on the same PR can | |
| # never cancel each other, and only the takeover label routes at all | |
| # (unrelated labels are filtered at the job gate); issue_comment → its own per-PR command group, but | |
| # ONLY when the commenter's payload association already looks trusted | |
| # (same prefilter pattern as reviews — an untrusted commenter must not | |
| # cancel a maintainer's queued command; untrusted payloads get a | |
| # run-unique group and still face the real permission gate inside), so | |
| # a burst of trusted command comments coalesces to at most two runs | |
| # with latest-intent semantics, never touching review routes; | |
| # issues → per-issue; anything else (dispatch) → unique per run_id, | |
| # never cancelled. | |
| group: >- | |
| ${{ github.event_name == 'schedule' && 'qwen-autofix-route-cron' || (github.event_name == 'pull_request_review' && (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association) || github.event.review.user.login == 'qwen-code-ci-bot') && format('qwen-autofix-route-pr-{0}', github.event.pull_request.number)) || (github.event_name == 'pull_request' && github.event.label.name == 'autofix/takeover' && format('qwen-autofix-route-label-{0}', github.event.pull_request.number)) || (github.event_name == 'issue_comment' && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && format('qwen-autofix-route-cmd-{0}', github.event.issue.number)) || (github.event_name == 'issues' && format('qwen-autofix-route-issue-{0}', github.event.issue.number)) || format('qwen-autofix-route-{0}', github.run_id) }} | |
| cancel-in-progress: |- | |
| ${{ github.event_name != 'workflow_dispatch' }} | |
| permissions: | |
| contents: 'read' | |
| outputs: | |
| do_issue: '${{ steps.decide.outputs.do_issue }}' | |
| do_review: '${{ steps.decide.outputs.do_review }}' | |
| dry_run: '${{ steps.decide.outputs.dry_run }}' | |
| issue_number: '${{ steps.decide.outputs.issue_number }}' | |
| pr_number: '${{ steps.decide.outputs.pr_number }}' | |
| takeover_ack: '${{ steps.decide.outputs.takeover_ack }}' | |
| ack_pr: '${{ steps.decide.outputs.ack_pr }}' | |
| ack_base: '${{ steps.decide.outputs.ack_base }}' | |
| takeover_cmd: '${{ steps.decide.outputs.takeover_cmd }}' | |
| retry_pr: '${{ steps.decide.outputs.retry_pr }}' | |
| cmd_pr: '${{ steps.decide.outputs.cmd_pr }}' | |
| steps: | |
| - name: 'Decide phases' | |
| id: 'decide' | |
| env: | |
| PHASE: '${{ inputs.phase }}' | |
| FORCED_ISSUE: '${{ inputs.issue_number }}' | |
| FORCED_PR: '${{ inputs.pr_number }}' | |
| DRY_RUN_INPUT: '${{ inputs.dry_run }}' | |
| EVENT_NAME: '${{ github.event_name }}' | |
| GITHUB_TOKEN: '${{ github.token }}' | |
| BUG_LABEL: 'type/bug' | |
| ISSUE_LABEL: '${{ github.event.label.name }}' | |
| ISSUE_LABELS_JSON: '${{ toJSON(github.event.issue.labels.*.name) }}' | |
| ISSUE_NUMBER: '${{ github.event.issue.number }}' | |
| ISSUE_STATE: '${{ github.event.issue.state }}' | |
| READY_FOR_AGENT_LABEL: 'status/ready-for-agent' | |
| AUTOFIX_APPROVED_LABEL: 'autofix/approved' | |
| REPO: '${{ github.repository }}' | |
| SENDER_LOGIN: '${{ github.event.sender.login }}' | |
| ASSIGNEE_LOGIN: '${{ github.event.assignee.login }}' | |
| SCHEDULE: '${{ github.event.schedule }}' | |
| PR_AUTHOR: '${{ github.event.pull_request.user.login }}' | |
| PR_NUMBER_EVENT: '${{ github.event.pull_request.number }}' | |
| PR_HEAD_REPO: '${{ github.event.pull_request.head.repo.full_name }}' | |
| PR_BASE_REF: '${{ github.event.pull_request.base.ref }}' | |
| PR_STATE: '${{ github.event.pull_request.state }}' | |
| EVENT_ACTION: '${{ github.event.action }}' | |
| COMMENT_BODY: '${{ github.event.comment.body }}' | |
| COMMENT_PR_AUTHOR: '${{ github.event.issue.user.login }}' | |
| HAS_PR_URL: '${{ github.event.issue.pull_request.url }}' | |
| run: |- | |
| DO_ISSUE=false | |
| DO_REVIEW=false | |
| TAKEOVER_ACK='' | |
| ACK_BASE='' | |
| TAKEOVER_CMD='' | |
| CMD_PR='' | |
| RETRY_PR='' | |
| DRY_RUN="${DRY_RUN_INPUT:-false}" | |
| sanitize_number() { | |
| local value="${1//$'\r'/}" | |
| value="${value//$'\n'/}" | |
| if [[ "${value}" =~ ^[0-9]+$ ]]; then | |
| printf '%s' "${value}" | |
| elif [[ -n "${value}" ]]; then | |
| echo "::warning::Rejected non-numeric routing input: '${value}'" >&2 | |
| fi | |
| } | |
| # workflow_dispatch inputs are user-controlled; keep GITHUB_OUTPUT | |
| # routing values single-line numeric before later jobs consume them. | |
| ROUTE_ISSUE="$(sanitize_number "${FORCED_ISSUE}")" | |
| ROUTE_PR="$(sanitize_number "${FORCED_PR}")" | |
| case "${PHASE}" in | |
| issue) DO_ISSUE=true ;; | |
| review) DO_REVIEW=true ;; | |
| both) DO_ISSUE=true; DO_REVIEW=true ;; | |
| *) | |
| # auto only runs review from scheduled/manual events. Label events | |
| # route below after their trust gates pass. | |
| if [[ "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ]]; then | |
| DO_REVIEW=true | |
| fi | |
| # 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 | |
| # Real-time review triggers: process the SAME managed set the | |
| # scheduled scan does, so feedback is picked up seconds after the | |
| # review instead of waiting for a schedule GitHub throttles hard | |
| # (the */10 cron actually lands every 40-70min on this repo). | |
| # Reviews must come from trusted senders (collaborators or the | |
| # review bot) so arbitrary commenters cannot force expensive | |
| # review-scan runs. Only pull_request_review:submitted triggers | |
| # (not per-comment events) to avoid redundant runs on | |
| # multi-comment reviews. | |
| if [[ "${EVENT_NAME}" == 'pull_request_review' ]]; then | |
| DO_ISSUE=false | |
| pr_is_managed=false | |
| if [[ "${PR_BASE_REF}" != "main" ]]; then | |
| echo "🧭 review event ignored: PR targets '${PR_BASE_REF}' not 'main'" | |
| elif [[ "${PR_HEAD_REPO}" == "${REPO}" ]]; then | |
| if [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then | |
| pr_is_managed=true | |
| else | |
| echo "🧭 review event ignored: PR author '${PR_AUTHOR}' is not ${AUTOFIX_BOT}" | |
| fi | |
| else | |
| # Fork PR. The scheduled scan already admits these for | |
| # takeover, so real-time pickup applies the SAME admission | |
| # (allow-edits on, and either the bot's own fork or an | |
| # explicit TAKEOVER_LABEL) rather than making the takeover | |
| # PRs — the ones a maintainer is actively waiting on — sit | |
| # through a throttled schedule. This event runs in BASE-repo | |
| # context, and review-address independently re-verifies | |
| # allow-edits, a live write+ author and a matching live head | |
| # repo before it touches the branch, so this only decides | |
| # WHEN that same gated work happens, never whether it may. | |
| fork_meta='' | |
| if fork_meta="$(gh pr view "${PR_NUMBER_EVENT}" --repo "${REPO}" --json labels,maintainerCanModify 2> /dev/null)"; then | |
| fork_allows_edits="$(jq -r '.maintainerCanModify == true' <<< "${fork_meta}")" | |
| fork_has_takeover="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${fork_meta}")" | |
| if [[ "${fork_allows_edits}" != 'true' ]]; then | |
| echo "🧭 review event ignored: fork PR #${PR_NUMBER_EVENT} does not allow maintainer edits" | |
| elif [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" || "${fork_has_takeover}" == 'true' ]]; then | |
| pr_is_managed=true | |
| else | |
| echo "🧭 review event ignored: fork PR #${PR_NUMBER_EVENT} is neither ${AUTOFIX_BOT}'s own fork nor ${TAKEOVER_LABEL}-labeled" | |
| fi | |
| else | |
| echo "🧭 review event ignored: could not read fork PR #${PR_NUMBER_EVENT} metadata" | |
| fi | |
| fi | |
| if [[ "${pr_is_managed}" == 'true' ]]; then | |
| # Verify the reviewer/commenter is trusted (prompt-injection gate). | |
| sender_permission='' | |
| sender_is_trusted=false | |
| if [[ "${SENDER_LOGIN}" == "${REVIEW_BOT}" ]]; then | |
| sender_is_trusted=true | |
| elif [[ -n "${SENDER_LOGIN}" ]]; then | |
| api_error_file="$(mktemp)" | |
| if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then | |
| case "${sender_permission}" in | |
| admin|maintain|write) sender_is_trusted=true ;; | |
| esac | |
| else | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error:-unknown error}" | |
| sender_permission='' | |
| fi | |
| rm -f "${api_error_file}" | |
| fi | |
| if [[ "${sender_is_trusted}" == "true" ]]; then | |
| DO_REVIEW=true | |
| ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")" | |
| echo "🧭 review event on bot PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} (${sender_permission:-review-bot}) → review phase" | |
| else | |
| echo "🧭 review event ignored: sender '${SENDER_LOGIN}' permission='${sender_permission:-none}' is not trusted" | |
| fi | |
| fi | |
| fi | |
| # Comment-command sugar over the labels: TAKEOVER_COMMAND | |
| # applies TAKEOVER_LABEL, 'TAKEOVER_COMMAND stop' removes it — | |
| # nothing else. The label stays the single source of truth: | |
| # engagement and release happen ONLY via the label events | |
| # below; the command also posts acks directly in both | |
| # directions (#7999, #8002). Exact match on the trimmed body (constants, never | |
| # user-input parsing); allowed senders: the PR author (who may | |
| # lack label access) or a write+ collaborator. This immediately | |
| # narrows a previously fully-closed surface reopened under | |
| # maintainer mandate. | |
| if [[ "${EVENT_NAME}" == 'issue_comment' ]]; then | |
| DO_ISSUE=false | |
| DO_REVIEW=false | |
| BODY_TRIMMED="$(printf '%s' "${COMMENT_BODY}" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" | |
| CMD='' | |
| [[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND}" ]] && CMD='add' | |
| [[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND} stop" ]] && CMD='remove' | |
| # Re-arm shares the takeover command's authorization exactly: | |
| # both summon bot activity on a managed PR, so inventing a | |
| # second policy would only add surface. | |
| RETRY_REQ='' | |
| [[ "${BODY_TRIMMED}" == "${RETRY_COMMAND}" ]] && RETRY_REQ='true' | |
| if [[ -z "${HAS_PR_URL}" ]]; then | |
| echo "🧭 command ignored: not a PR comment" | |
| elif [[ -z "${CMD}" && -z "${RETRY_REQ}" ]]; then | |
| echo "🧭 command ignored: body is not an exact command" | |
| elif [[ "${ISSUE_STATE}" != 'open' ]]; then | |
| echo "🧭 command ignored: PR is not open" | |
| elif [[ -z "${SENDER_LOGIN}" || "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then | |
| echo "🧭 command ignored: sender '${SENDER_LOGIN:-n/a}'" | |
| else | |
| sender_is_authorized=false | |
| sender_permission='' | |
| # Author privilege applies to IN-REPO PRs only: on a fork | |
| # PR the author is an arbitrary external account, and | |
| # accepting them here would let them drive PAT-authored | |
| # writes (even just refusal comments) onto their own PR at | |
| # will. Fork authors without write+ are dropped silently. | |
| CMD_HEAD_REPO="$(gh api "repos/${REPO}/pulls/${ISSUE_NUMBER}" --jq '.head.repo.full_name // ""' 2> /dev/null || echo '')" | |
| if [[ "${SENDER_LOGIN}" == "${COMMENT_PR_AUTHOR}" && "${CMD_HEAD_REPO}" == "${REPO}" ]]; then | |
| # Author privilege is LIVE, not durable: an author removed | |
| # from the repo keeps their PR/head-repo match forever, so | |
| # authorship alone must not keep summoning secret-bearing | |
| # runs. Authors qualify at triage+ (the sugar exists for | |
| # members below write who cannot apply labels). | |
| api_error_file="$(mktemp)" | |
| if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then | |
| case "${sender_permission}" in | |
| admin|maintain|write|triage) sender_is_authorized=true; sender_permission="pr-author/${sender_permission}" ;; | |
| esac | |
| else | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| echo "::warning::Permission API call failed for author ${SENDER_LOGIN}: ${api_error:-unknown error}" | |
| sender_permission='' | |
| fi | |
| rm -f "${api_error_file}" | |
| else | |
| api_error_file="$(mktemp)" | |
| if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then | |
| case "${sender_permission}" in | |
| admin|maintain|write) sender_is_authorized=true ;; | |
| esac | |
| else | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error:-unknown error}" | |
| sender_permission='' | |
| fi | |
| rm -f "${api_error_file}" | |
| fi | |
| if [[ "${sender_is_authorized}" == 'true' ]]; then | |
| if [[ -n "${RETRY_REQ}" ]]; then | |
| RETRY_PR="$(sanitize_number "${ISSUE_NUMBER}")" | |
| echo "🧭 retry command accepted: re-arm PR #${ISSUE_NUMBER} by ${SENDER_LOGIN} (${sender_permission})" | |
| else | |
| TAKEOVER_CMD="${CMD}" | |
| CMD_PR="$(sanitize_number "${ISSUE_NUMBER}")" | |
| echo "🧭 takeover command accepted: ${CMD} ${TAKEOVER_LABEL} on PR #${ISSUE_NUMBER} by ${SENDER_LOGIN} (${sender_permission})" | |
| fi | |
| else | |
| echo "🧭 command ignored: sender '${SENDER_LOGIN}' permission='${sender_permission:-none}' is not the PR author or write+" | |
| fi | |
| fi | |
| fi | |
| # Label-driven takeover: TAKEOVER_LABEL applied to an eligible | |
| # PR summons the loop onto it (human-authored included); | |
| # removing it releases the PR — future engagement stops, and an | |
| # in-flight round, if any, completes its bounded work (matrix | |
| # runs are shared across PRs, so cancelling one is not possible | |
| # without collateral damage). ISSUE_LABEL carries the label | |
| # name for pull_request events too (same payload field). | |
| if [[ "${EVENT_NAME}" == 'pull_request' ]]; then | |
| DO_ISSUE=false | |
| if [[ "${ISSUE_LABEL}" != "${TAKEOVER_LABEL}" ]]; then | |
| echo "🧭 pull_request ${EVENT_ACTION} ignored: label '${ISSUE_LABEL:-n/a}' is not ${TAKEOVER_LABEL}" | |
| elif [[ "${EVENT_ACTION}" == 'labeled' ]]; then | |
| if [[ "${PR_HEAD_REPO}" != "${REPO}" ]]; then | |
| # Fork pull_request events carry NO secrets, so neither | |
| # the immediate scan nor the ack job can run from this | |
| # event. The label still counts: the next scheduled scan | |
| # (repo context, ≤10m) admits fork takeover PRs whose | |
| # author holds write+ and whose PR allows maintainer | |
| # edits, and posts the engage ack on first pickup. | |
| echo "🧭 fork takeover noted for #${PR_NUMBER_EVENT} — the next scheduled scan engages (author write+ and allow-edits required)" | |
| elif [[ "${PR_STATE}" != 'open' ]]; then | |
| echo "🧭 takeover ignored: PR state '${PR_STATE:-unknown}' is not open" | |
| elif [[ "${PR_BASE_REF}" != 'main' ]]; then | |
| # Refuse OUT LOUD. Staying silent here made a labelled | |
| # stacked PR indistinguishable from a managed one: the | |
| # label stuck, the route run went green, and the only | |
| # trace was this log line — so the PR sat unmanaged for | |
| # hours with nobody able to tell without reading the job | |
| # log. The ack job posts the explanation instead. | |
| TAKEOVER_ACK='base-refused' | |
| ACK_BASE="${PR_BASE_REF}" | |
| echo "🧭 takeover ignored: PR targets '${PR_BASE_REF}' not 'main'" | |
| else | |
| DO_REVIEW=true | |
| ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")" | |
| if [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then | |
| # The bot only applies this label from takeover-command, | |
| # which posts the engage ack ITSELF: the labeled event | |
| # has been observed to simply not fire (#7999 — the | |
| # author read the silence as failure and removed the | |
| # label; #8002), so the user-visible ack must not | |
| # depend on this round-trip. Suppress only the ack — | |
| # the immediate scan is this event's real work and | |
| # still routes. | |
| echo "🧭 engage ack skipped: label applied by ${AUTOFIX_BOT} — the command path already acked" | |
| else | |
| TAKEOVER_ACK='engaged' | |
| fi | |
| echo "🧭 ${TAKEOVER_LABEL} applied by ${SENDER_LOGIN} on PR #${PR_NUMBER_EVENT} → review phase (takeover)" | |
| fi | |
| elif [[ "${EVENT_ACTION}" == 'unlabeled' ]]; then | |
| # Mirror the labeled-path guards: a fork, closed, or | |
| # non-main PR was never engaged, so a release ack would | |
| # announce a disengagement that never existed. | |
| if [[ "${PR_STATE}" != 'open' || "${PR_BASE_REF}" != 'main' ]]; then | |
| echo "🧭 takeover release ignored: PR state '${PR_STATE:-unknown}' base '${PR_BASE_REF:-unknown}' was never engaged" | |
| elif [[ "${PR_HEAD_REPO}" != "${REPO}" ]]; then | |
| # Fork pull_request events carry no secrets: emitting the | |
| # ack here would start takeover-ack with an empty PAT and | |
| # fail its identity check — a red run for a label that | |
| # never engaged anything. Log and stop. | |
| echo "🧭 takeover release ignored: PR is a fork (${PR_HEAD_REPO} != ${REPO})" | |
| elif [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then | |
| # Mirror of the labeled-path suppression: the bot only | |
| # removes this label from takeover-command, which posts | |
| # the release ack itself — acking here too would | |
| # double-post on every command-driven stop. | |
| echo "🧭 release ack skipped: label removed by ${AUTOFIX_BOT} — the command path already acked" | |
| else | |
| TAKEOVER_ACK='released' | |
| echo "🧭 ${TAKEOVER_LABEL} removed from PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} → released" | |
| fi | |
| fi | |
| fi | |
| if [[ "${EVENT_NAME}" == 'issues' ]]; then | |
| DO_REVIEW=false | |
| label_is_trigger=false | |
| [[ "${ISSUE_LABEL}" == "${READY_FOR_AGENT_LABEL}" || "${ISSUE_LABEL}" == "${BUG_LABEL}" || "${ISSUE_LABEL}" == "${AUTOFIX_APPROVED_LABEL}" ]] && label_is_trigger=true | |
| [[ "${ASSIGNEE_LOGIN}" == "${AUTOFIX_BOT}" ]] && label_is_trigger=true | |
| sender_permission='' | |
| sender_is_trusted=false | |
| if [[ -n "${SENDER_LOGIN}" ]]; then | |
| if ! sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>&1)"; then | |
| api_error="${sender_permission}" | |
| sender_permission='' | |
| api_error="${api_error//$'\r'/ }" | |
| api_error="${api_error//$'\n'/ }" | |
| echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error}" | |
| fi | |
| [[ "${sender_permission}" == 'write' || "${sender_permission}" == 'maintain' || "${sender_permission}" == 'admin' ]] && sender_is_trusted=true | |
| fi | |
| if [[ "${label_is_trigger}" != 'true' ]]; then | |
| # A non-trigger label (e.g. scope/*, priority/*) may arrive | |
| # after the trigger labels and cancel their runs via per-issue | |
| # concurrency. If the issue already carries both required | |
| # labels and the current sender is trusted, proceed anyway. | |
| _late_ready="$(jq -r --arg l "${READY_FOR_AGENT_LABEL}" 'index($l) != null' <<< "${ISSUE_LABELS_JSON:-[]}")" | |
| _late_approved="$(jq -r --arg l "${AUTOFIX_APPROVED_LABEL}" 'index($l) != null' <<< "${ISSUE_LABELS_JSON:-[]}")" | |
| if [[ "${ISSUE_STATE}" == 'open' && "${_late_ready}" == 'true' && "${_late_approved}" == 'true' && "${sender_is_trusted}" == 'true' ]]; then | |
| echo "🧭 non-trigger label '${ISSUE_LABEL:-n/a}' but issue #${ISSUE_NUMBER} already approved+ready → issue phase" | |
| DO_ISSUE=true | |
| else | |
| echo "🧭 issue event ignored: trigger_label=false label='${ISSUE_LABEL:-n/a}' issue='#${ISSUE_NUMBER:-n/a}'" | |
| fi | |
| else | |
| issue_is_bug="$(jq -r --arg label "${BUG_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")" | |
| issue_is_ready="$(jq -r --arg label "${READY_FOR_AGENT_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")" | |
| issue_is_approved="$(jq -r --arg label "${AUTOFIX_APPROVED_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")" | |
| if [[ "${ISSUE_STATE}" == 'open' && "${issue_is_ready}" == 'true' && "${issue_is_approved}" == 'true' && "${label_is_trigger}" == 'true' && "${sender_is_trusted}" == 'true' ]]; then | |
| DO_ISSUE=true | |
| else | |
| if [[ "${ISSUE_STATE}" == 'open' && "${label_is_trigger}" == 'true' && "${sender_is_trusted}" == 'true' && "${issue_is_ready}" != "${issue_is_approved}" ]]; then | |
| echo "::notice::Issue #${ISSUE_NUMBER:-n/a} needs both ${READY_FOR_AGENT_LABEL} and ${AUTOFIX_APPROVED_LABEL} before autofix can run." | |
| fi | |
| echo "🧭 issue event ignored: state_open=$([[ "${ISSUE_STATE}" == 'open' ]] && echo true || echo false) bug=${issue_is_bug} ready=${issue_is_ready} approved=${issue_is_approved} trigger_label=${label_is_trigger} sender_permission='${sender_permission:-none}' sender_trusted=${sender_is_trusted} label='${ISSUE_LABEL:-n/a}' issue='#${ISSUE_NUMBER:-n/a}'" | |
| fi | |
| fi | |
| fi | |
| ;; | |
| esac | |
| # Forcing a specific issue/PR implies running that phase only for | |
| # explicit manual dispatch. Event payload numbers still flow to the | |
| # phase jobs after routing, but must not bypass the label/schedule gates. | |
| # Explicit phases (issue/review/both) take precedence over forced | |
| # issue/PR overrides — only apply forced routing in auto/default mode. | |
| if [[ "${EVENT_NAME}" == 'workflow_dispatch' && ( -z "${PHASE}" || "${PHASE}" == 'auto' ) ]]; then | |
| [[ -n "${ROUTE_ISSUE}" && -z "${ROUTE_PR}" ]] && DO_ISSUE=true && DO_REVIEW=false | |
| [[ -n "${ROUTE_PR}" && -z "${ROUTE_ISSUE}" ]] && DO_ISSUE=false && DO_REVIEW=true | |
| [[ -n "${ROUTE_ISSUE}" && -n "${ROUTE_PR}" ]] && DO_ISSUE=true && DO_REVIEW=true | |
| fi | |
| echo "do_issue=${DO_ISSUE}" >> "${GITHUB_OUTPUT}" | |
| echo "do_review=${DO_REVIEW}" >> "${GITHUB_OUTPUT}" | |
| echo "dry_run=${DRY_RUN}" >> "${GITHUB_OUTPUT}" | |
| echo "issue_number=${ROUTE_ISSUE}" >> "${GITHUB_OUTPUT}" | |
| echo "pr_number=${ROUTE_PR}" >> "${GITHUB_OUTPUT}" | |
| echo "takeover_ack=${TAKEOVER_ACK}" >> "${GITHUB_OUTPUT}" | |
| echo "ack_pr=$(sanitize_number "${PR_NUMBER_EVENT}")" >> "${GITHUB_OUTPUT}" | |
| echo "ack_base=${ACK_BASE}" >> "${GITHUB_OUTPUT}" | |
| echo "takeover_cmd=${TAKEOVER_CMD}" >> "${GITHUB_OUTPUT}" | |
| echo "retry_pr=${RETRY_PR}" >> "${GITHUB_OUTPUT}" | |
| echo "cmd_pr=${CMD_PR}" >> "${GITHUB_OUTPUT}" | |
| 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', 'review-scan'] | |
| if: |- | |
| ${{ | |
| always() && | |
| needs.route.outputs.do_issue == 'true' && | |
| (github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')) | |
| }} | |
| # Secret-bearing and executes agent-driven code, but the agent runs inside | |
| # the docker sandbox image and only ever writes a new branch as the | |
| # dev-bot — it never executes a foreign author's code. Forks of this repo | |
| # (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to hosted. On | |
| # pull_request / pull_request_review events the ECS route additionally | |
| # needs a same-repo head or a write+ author (ci.yml's pick_runner form); | |
| # the other triggers skip that clause and rely on their own gates | |
| # instead: issues / schedule require autofix/approved plus | |
| # status/ready-for-agent on the issue, and workflow_dispatch rides the | |
| # actor's own write access. Docker availability on this pool is proven | |
| # in-repo by qwen-triage's container jobs, which run on the same | |
| # runner labels. | |
| runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' | |
| timeout-minutes: 180 | |
| # route.issue_number is only set for forced dispatches; label events carry | |
| # the issue in the payload, and scan-and-pick runs (cron, unforced | |
| # dispatch) share one 'scheduled' group. The old github.run_id fallback | |
| # made every scan-and-pick run its own group, so two overlapping scans | |
| # (cron fires every 40-70min, this job runs up to 180) could double-claim | |
| # the same issue — the claim recheck runs after assess and only narrows | |
| # the race to the short gap between the recheck and the claim's label | |
| # write; it does not close it. Queued (never cancelled) so the newest | |
| # pending tick still runs after a long scan if targets remain; | |
| # intermediate ticks are superseded, which is fine because each run | |
| # rescans from scratch. | |
| # | |
| # GitHub evaluates concurrency before the job `if`, but after `needs`, so | |
| # the group is gated on the same runnability predicate as the `if` above, | |
| # plus a dry-run exclusion: runs whose issue phase will not execute | |
| # (do_issue=false takeover, review, and command events; label events | |
| # failing the decide gates; scheduled ticks whose review-scan still has | |
| # targets) and dry runs (if-runnable, but their Claim/Publish steps are | |
| # gated off) get a run-unique group instead — a run that never claims | |
| # entering a target-keyed group would replace the single pending run | |
| # there and silently cancel it. Same precedent as qwen-triage.yml's | |
| # triage/tmux jobs. | |
| concurrency: | |
| group: >- | |
| ${{ needs.route.outputs.do_issue == 'true' && needs.route.outputs.dry_run != 'true' && (github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')) && format('qwen-autofix-issue-{0}', needs.route.outputs.issue_number || github.event.issue.number || 'scheduled') || format('qwen-autofix-issue-run-{0}', github.run_id) }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: 'read' | |
| env: | |
| REPO: '${{ github.repository }}' | |
| # Per-run private dir: this pool carries many registrations sharing one | |
| # OS /tmp, and issue-phase runs never serialize against each other, so | |
| # a fixed path let concurrent runs clobber each other's decision files. | |
| WORKDIR: '/tmp/autofix-${{ github.run_id }}' | |
| EVENT_NAME: '${{ github.event_name }}' | |
| READY_FOR_AGENT_LABEL: 'status/ready-for-agent' | |
| AUTOFIX_APPROVED_LABEL: 'autofix/approved' | |
| AUTOFIX_ISSUE_EXCLUDES: 'no:assignee -linked:pr -label:autofix/skip -label:autofix/in-progress -label:status/need-information -label:status/need-retesting sort:created-desc' | |
| steps: | |
| # Self-hosted runners reuse the workspace; a prior containerised job | |
| # can leave root-owned, read-only files anywhere in it. Restore | |
| # ownership and write permission unconditionally before checkout. | |
| - name: 'Restore workspace ownership' | |
| run: |- | |
| set -uo pipefail | |
| RUNNER_UID="$(id -u)" | |
| RUNNER_GID="$(id -g)" | |
| if [ "$RUNNER_UID" != "0" ]; then | |
| chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" | |
| fi | |
| chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" | |
| # Self-hosted runners keep the workspace between runs, and other pool | |
| # jobs execute human-authored code as the runner user, so a prior job | |
| # can plant git exec knobs (core.fsmonitor, filter.*.smudge, | |
| # diff.external, includeIf, hooks) in the local config that would fire | |
| # inside THIS job's PAT-bearing git steps. It keeps | |
| # a known-safe allowlist and unsets everything else, hardened against | |
| # the worktree-config and global-hooksPath bypasses verified in | |
| # qwen-triage on this pool. No-op on a fresh hosted runner. | |
| - name: 'Sanitize workspace git config' | |
| run: |- | |
| set -uo pipefail | |
| # `.git` is a directory in a normal checkout but a gitlink file in | |
| # a worktree; -e covers both, and a missing .git (first run) too. | |
| if [ ! -e .git ]; then | |
| echo "no prior workspace; nothing to sanitize" | |
| exit 0 | |
| fi | |
| # Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is | |
| # on the allowlist below (it carries no command itself), but it | |
| # activates `.git/config.worktree` — a second config file that | |
| # `git config --local` neither lists nor unsets, and that CAN carry | |
| # core.hooksPath. Verified in qwen-triage: a prior run can set | |
| # `--worktree core.hooksPath=/`, survive the sweep untouched, and | |
| # make the hooks deletion below walk /. Delete the file outright, | |
| # then drop the extension. | |
| rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true | |
| git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true | |
| # Rather than denylist each exec-vector family (which kept missing | |
| # new ones), KEEP a known-safe allowlist and --unset-all everything | |
| # else: this closes the whole class, including knobs not yet | |
| # enumerated. The kept set is only plumbing that carries no command | |
| # — repo format, remote, branch, fetch/gc/pack/index, safe.directory, | |
| # extensions, and submodule url/active/branch (NOT | |
| # submodule.*.update, which can be `!cmd`). actions/checkout | |
| # re-establishes remote/auth afterward. `|| true` on the grep: no | |
| # non-allowlisted keys (the steady state on an already-sanitized | |
| # runner) means grep exits 1, which would kill the step exactly | |
| # when there is nothing to clean. | |
| git config --local --name-only --list 2>/dev/null \ | |
| | { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \ | |
| | while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done | |
| # Belt and braces after the config scrub: only delete inside the | |
| # repository's own git dir. A hooks path resolving anywhere else is | |
| # unlinked, never swept — a recursive delete of a planted path is | |
| # far worse than a stale hook on a runner the pool re-cleans. | |
| # Resolve hooks with global/system config OUT of the way. Verified | |
| # in qwen-triage: with a global core.hooksPath set, `git rev-parse | |
| # --git-path hooks` returns that path, the guard below sees | |
| # "outside the git dir", and a planted `.git/hooks` symlink | |
| # survives untouched. Keep this resolution AFTER the sweep above. | |
| GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')" | |
| HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)" | |
| HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')" | |
| if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then | |
| # Match -type f OR -type l: a symlinked hook survives a bare | |
| # `-type f` sweep and still fires on the next checkout. | |
| find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true | |
| else | |
| # Resolves outside the git dir (or not at all). Warning and | |
| # walking away would leave a live hook directory that the next | |
| # git command executes, so unlink the ENTRY without descending | |
| # into it and put an empty hooks directory back. | |
| RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)" | |
| echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it." | |
| rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean." | |
| mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true | |
| git config --local --unset-all core.hooksPath 2>/dev/null || true | |
| fi | |
| - name: 'Checkout' | |
| uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: 'Reset autofix workspace' | |
| run: |- | |
| rm -rf "${WORKDIR}" | |
| # 0700: the dir holds agent transcripts and decision files; the | |
| # sandbox container runs as this same user, so the tighter mode | |
| # costs the job nothing. umask at creation, not mkdir-then-chmod — | |
| # the chmod form leaves a world-readable window on this shared /tmp. | |
| (umask 077; mkdir -p "${WORKDIR}") | |
| # Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard | |
| # runner kill skips the always() teardown and run_id never repeats, | |
| # so nothing else ever reclaims them. | |
| find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true | |
| # The reused workspace's .git accumulates unreferenced objects | |
| # across fetch runs on this persistent pool; prune them. | |
| git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true | |
| # Self-hosted runners keep the workspace's .git across runs, so a | |
| # failed earlier attempt's local branch survives here: the agent's | |
| # branch create then dies "branch already exists", or an adaptation | |
| # checks out the stale line and pushes the failed attempt's commits | |
| # into the new PR. Drop them deterministically (refs survive | |
| # actions/checkout's untracked-file clean). | |
| - name: 'Drop stale autofix branches' | |
| run: |- | |
| # Detach first: `git branch -D` refuses the currently checked-out | |
| # branch, so a stale autofix branch holding HEAD would otherwise | |
| # silently survive the sweep (actions/checkout normally leaves | |
| # HEAD on the default branch; this makes the sweep unconditional). | |
| git checkout --detach 2>/dev/null || true | |
| git for-each-ref --format='%(refname:short)' "refs/heads/${BRANCH_PREFIX}*" \ | |
| | xargs -r -n 1 git branch -D 2>/dev/null || true | |
| # Same staging as the review-address job: the verify gate always runs the | |
| # trusted checkout's copy of the schema gate, never a working-tree copy. | |
| - name: 'Stage trusted schema gate' | |
| run: |- | |
| cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh" | |
| cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh" | |
| cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" | |
| - name: 'Check bot credentials' | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| run: |- | |
| if [[ -z "${GITHUB_TOKEN}" ]]; then | |
| echo '::error::CI_DEV_BOT_PAT is required to run the issue autofix job.' | |
| exit 1 | |
| fi | |
| api_error_file="$(mktemp)" | |
| if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}" | |
| if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| - name: 'Check runner environment' | |
| env: | |
| RUNNER_ENVIRONMENT: '${{ runner.environment }}' | |
| RUNNER_NAME: '${{ runner.name }}' | |
| run: |- | |
| case "${RUNNER_ENVIRONMENT}" in | |
| github-hosted|self-hosted) ;; | |
| *) | |
| echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}." | |
| exit 1 | |
| ;; | |
| esac | |
| # The label routing pins ecs-qwen, but a mis-labelled registration | |
| # must not silently claim a PAT-bearing 300-minute job — assert the | |
| # pool by name on the self-hosted branch too. | |
| if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then | |
| case "${RUNNER_NAME}" in | |
| ecs-qwen-*) ;; | |
| *) | |
| echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here." | |
| exit 1 | |
| ;; | |
| esac | |
| fi | |
| # Capability preflight for the persistent pool: this job's agent | |
| # runs inside the docker sandbox, and a missing daemon otherwise | |
| # surfaces only at 'Resolve sandbox image' — after npm ci/build | |
| # has already burned tens of minutes. Fail in seconds instead. | |
| # Hosted runners ship docker; the ECS pool's docker is proven by | |
| # qwen-triage's container jobs on the same labels. | |
| if ! docker info > /dev/null 2>&1; then | |
| echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start." | |
| exit 1 | |
| fi | |
| - name: 'Set up Node.js' | |
| uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 | |
| with: | |
| node-version: '22.x' | |
| cache: 'npm' | |
| cache-dependency-path: 'package-lock.json' | |
| - name: 'Install tmux' | |
| run: |- | |
| if command -v tmux > /dev/null 2>&1; then | |
| tmux -V | |
| elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then | |
| # sudo -n: a host without passwordless sudo must fail fast with a | |
| # clear message, not die on a password prompt (the pr-review pool | |
| # steps make the same assumption with `sudo -n ... || ::warning`). | |
| sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || { | |
| echo '::error::tmux is required on the autofix runner and passwordless install failed.' | |
| exit 1 | |
| } | |
| else | |
| echo '::error::tmux is required on the autofix runner.' | |
| exit 1 | |
| fi | |
| # The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below | |
| # are duplicated in build-cli and review-address; the workflow | |
| # contract tests pin every copy in lockstep — edit them together. | |
| - name: 'Install dependencies and build' | |
| env: | |
| QWEN_SKIP_PREPARE: '1' | |
| run: |- | |
| for attempt in 1 2 3; do | |
| if npm ci --prefer-offline --no-audit --progress=false; then | |
| break | |
| fi | |
| if [[ "${attempt}" == "3" ]]; then | |
| exit 1 | |
| fi | |
| sleep $((attempt * 15)) | |
| done | |
| git config core.hooksPath .husky | |
| npm run build | |
| npm run bundle | |
| - name: 'Prepare Qwen Code CLI' | |
| run: |- | |
| qwen_version="$(node -p "require('./package.json').version")" | |
| echo "Using checked-out Qwen Code bundle ${qwen_version}" | |
| qwen_bin="${RUNNER_TEMP}/qwen-bin" | |
| mkdir -p "${qwen_bin}" | |
| cat > "${qwen_bin}/qwen" <<'EOF' | |
| #!/usr/bin/env bash | |
| exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@" | |
| EOF | |
| chmod +x "${qwen_bin}/qwen" | |
| echo "${qwen_bin}" >> "${GITHUB_PATH}" | |
| PATH="${qwen_bin}:${PATH}" | |
| qwen --version | |
| - name: 'Find candidate issues' | |
| id: 'scan' | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| # Must resolve to the same issue as this job's concurrency group | |
| # expression; a test pins the two equal. | |
| 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}" | |
| forced_issue_json="${WORKDIR}/forced-issue.json" | |
| gh issue view "${FORCED_ISSUE}" --repo "${REPO}" \ | |
| --json number,title,body,labels,createdAt,url,state \ | |
| > "${forced_issue_json}" | |
| if jq -e \ | |
| '(.labels // []) | map(.name) | any(. == "autofix/skip" or . == "autofix/in-progress")' \ | |
| "${forced_issue_json}" > /dev/null; then | |
| echo "⏭️ Forced issue #${FORCED_ISSUE} has an autofix exclusion label; skipping." | |
| jq -n -c '[]' > "${WORKDIR}/candidates.json" | |
| elif [[ "$(jq -r '.state // ""' "${forced_issue_json}")" != 'OPEN' ]]; then | |
| echo "⏭️ Forced issue #${FORCED_ISSUE} is not open; skipping." | |
| jq -n -c '[]' > "${WORKDIR}/candidates.json" | |
| # workflow_dispatch is a maintainer-initiated escape hatch, so it | |
| # intentionally bypasses the label gates that protect event/cron | |
| # paths from issue-content prompt injection. | |
| elif [[ "${EVENT_NAME}" != 'workflow_dispatch' ]] && ! jq -e --arg ready "${READY_FOR_AGENT_LABEL}" \ | |
| '(.labels // []) | map(.name) as $labels | ($labels | index($ready))' \ | |
| "${forced_issue_json}" > /dev/null; then | |
| echo "⏭️ Forced issue #${FORCED_ISSUE} is missing ${READY_FOR_AGENT_LABEL}; skipping." | |
| jq -n -c '[]' > "${WORKDIR}/candidates.json" | |
| elif [[ "${EVENT_NAME}" != 'workflow_dispatch' ]] && ! jq -e --arg approved "${AUTOFIX_APPROVED_LABEL}" \ | |
| '(.labels // []) | map(.name) as $labels | ($labels | index($approved))' \ | |
| "${forced_issue_json}" > /dev/null; then | |
| echo "⏭️ Forced issue #${FORCED_ISSUE} is missing ${AUTOFIX_APPROVED_LABEL}; skipping." | |
| jq -n -c '[]' > "${WORKDIR}/candidates.json" | |
| else | |
| if ! jq -c '[. + {autofixTier: 0}]' "${forced_issue_json}" > "${WORKDIR}/candidates.json"; then | |
| echo "::warning::Forced issue #${FORCED_ISSUE} processing failed; falling back to an empty candidate list." | |
| jq -n -c '[]' > "${WORKDIR}/candidates.json" | |
| fi | |
| fi | |
| else | |
| 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." | |
| jq -n -c '[]' > "${WORKDIR}/candidates.json" | |
| fi | |
| fi | |
| fi | |
| fi | |
| COUNT="$(jq length "${WORKDIR}/candidates.json")" | |
| if [[ "${COUNT}" -gt 0 ]]; 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." | |
| 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((.isCrossRepository != true) and ((.headRefName // "") == $branch)) | { | |
| number, | |
| headRefName | |
| }) // null | |
| ) as $existing | |
| | . + {existingAutofixPr: $existing} | |
| ) | |
| ' "${WORKDIR}/candidates.json" > "${WORKDIR}/annotated-candidates.json"; then | |
| echo "::warning::Open autofix PR annotation failed; candidates will proceed without duplicate-PR annotation." | |
| else | |
| mv "${WORKDIR}/annotated-candidates.json" "${WORKDIR}/candidates.json" | |
| CANDIDATES_WITH_PRS="$(jq '[.[] | select(.existingAutofixPr != null)] | length' "${WORKDIR}/candidates.json")" | |
| if [[ "${CANDIDATES_WITH_PRS}" -gt 0 ]]; then | |
| echo "ℹ️ ${CANDIDATES_WITH_PRS} candidate(s) already have open autofix PRs; the skill must skip them." | |
| fi | |
| fi | |
| fi | |
| fi | |
| COUNT="$(jq length "${WORKDIR}/candidates.json")" | |
| echo "📋 ${COUNT} candidate(s) found" | |
| if [[ "${COUNT}" -gt 0 ]]; then | |
| OLDEST_CREATED="$(jq -r 'map(.createdAt) | min' "${WORKDIR}/candidates.json")" | |
| NEWEST_CREATED="$(jq -r 'map(.createdAt) | max' "${WORKDIR}/candidates.json")" | |
| echo "🕒 Candidate createdAt range: ${OLDEST_CREATED} .. ${NEWEST_CREATED}" | |
| fi | |
| echo "has_candidates=$([[ "${COUNT}" -gt 0 ]] && echo true || echo false)" >> "${GITHUB_OUTPUT}" | |
| - name: 'Resolve sandbox image' | |
| if: |- | |
| ${{ steps.scan.outputs.has_candidates == 'true' }} | |
| run: |- | |
| node .github/scripts/resolve-sandbox-image.mjs \ | |
| "$(node -p "require('./package.json').config.sandboxImageUri")" | |
| - name: 'Fast-track decision' | |
| id: 'fasttrack' | |
| if: |- | |
| ${{ steps.scan.outputs.has_candidates == 'true' }} | |
| env: | |
| EVENT_NAME: '${{ github.event_name }}' | |
| FORCED_ISSUE: '${{ inputs.issue_number }}' | |
| run: |- | |
| FAST_TRACK=false | |
| if [[ "${EVENT_NAME}" == 'workflow_dispatch' && -n "${FORCED_ISSUE}" ]]; then | |
| FAST_TRACK=true | |
| fi | |
| if [[ "${EVENT_NAME}" == 'issues' ]]; then | |
| FAST_TRACK=true | |
| fi | |
| if [[ "${FAST_TRACK}" == 'true' ]]; then | |
| ISSUE_NUM="$(jq -r '.[0].number' "${WORKDIR}/candidates.json")" | |
| jq -n -c --argjson num "${ISSUE_NUM}" \ | |
| '{go: $num, reason: "Fast-tracked: trusted trigger bypasses LLM assessment.", skip: []}' \ | |
| > "${WORKDIR}/decision.json" | |
| echo "⚡ Fast-track decision: issue #${ISSUE_NUM}" | |
| echo 'fast_tracked=true' >> "${GITHUB_OUTPUT}" | |
| else | |
| echo 'fast_tracked=false' >> "${GITHUB_OUTPUT}" | |
| fi | |
| - name: 'Assess candidates' | |
| id: 'assess' | |
| if: |- | |
| ${{ steps.scan.outputs.has_candidates == 'true' && steps.fasttrack.outputs.fast_tracked != 'true' }} | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}' | |
| OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}' | |
| OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| NO_PROXY: '127.0.0.1,localhost,::1' | |
| QWEN_HOME: '${{ runner.temp }}/qwen-autofix-home' | |
| SETTINGS_JSON: |- | |
| { | |
| "maxSessionTurns": 60, | |
| "coreTools": [ | |
| "read_file", | |
| "read_many_files", | |
| "glob", | |
| "search_file_content", | |
| "write_file", | |
| "run_shell_command(cat)", | |
| "run_shell_command(git log)", | |
| "run_shell_command(git diff)" | |
| ], | |
| "tools": { | |
| "sandbox": "docker" | |
| } | |
| } | |
| run: |- | |
| rm -rf "${QWEN_HOME}" | |
| mkdir -p .qwen "${QWEN_HOME}" | |
| if [[ -z "${OPENAI_API_KEY:-}" ]]; then | |
| echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.' | |
| exit 1 | |
| fi | |
| printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json | |
| rm -f "${WORKDIR}/decision.json" "${WORKDIR}/failure.md" | |
| node .qwen/skills/autofix/scripts/run-agent.mjs \ | |
| --mode assess-candidates \ | |
| --workdir "${WORKDIR}" | |
| - name: 'Read decision' | |
| id: 'decision' | |
| if: |- | |
| ${{ steps.scan.outputs.has_candidates == 'true' }} | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| DRY_RUN: '${{ needs.route.outputs.dry_run }}' | |
| EVENT_NAME: '${{ github.event_name }}' | |
| run: |- | |
| if [[ ! -s "${WORKDIR}/decision.json" ]] || ! jq -e . "${WORKDIR}/decision.json" > /dev/null; then | |
| echo "❌ Assessment produced no valid decision.json" | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| GO="$(jq -r '.go // empty' "${WORKDIR}/decision.json")" | |
| if [[ -n "${GO}" && ! "${GO}" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "❌ Assessment produced an invalid issue number" | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| CANDIDATE_NUMS="$(jq -r '.[].number' "${WORKDIR}/candidates.json")" | |
| if [[ -n "${GO}" ]] && ! grep -qx "${GO}" <<< "${CANDIDATE_NUMS}"; then | |
| echo "❌ Assessment selected issue #${GO} which is not in the candidate list" | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| if [[ -n "${GO}" ]]; then | |
| EXISTING_PR="$(jq -r --argjson go "${GO}" ' | |
| first(.[] | select(.number == $go) | .existingAutofixPr.number) // empty | |
| ' "${WORKDIR}/candidates.json")" | |
| if [[ -n "${EXISTING_PR}" ]]; then | |
| echo "⏭️ Selected issue #${GO} already has open autofix PR #${EXISTING_PR}; skipping issue develop." | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| fi | |
| if [[ -n "${GO}" && "${DRY_RUN}" != "true" && "${EVENT_NAME}" != 'workflow_dispatch' ]]; then | |
| if ! live_issue_json="$(gh issue view "${GO}" --repo "${REPO}" --json labels,state)"; then | |
| echo "::warning::Failed to re-validate live labels for issue #${GO}; skipping due to API error" | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| if [[ "$(jq -r '.state // ""' <<< "${live_issue_json}")" != 'OPEN' ]]; then | |
| echo "⏭️ Selected issue #${GO} is no longer open; skipping." | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| if ! jq -e --arg ready "${READY_FOR_AGENT_LABEL}" --arg approved "${AUTOFIX_APPROVED_LABEL}" \ | |
| '(.labels // []) | map(.name) as $labels | (($labels | index($ready)) and ($labels | index($approved)))' \ | |
| <<< "${live_issue_json}" > /dev/null; then | |
| echo "⏭️ Selected issue #${GO} no longer has both ${READY_FOR_AGENT_LABEL} and ${AUTOFIX_APPROVED_LABEL}; skipping." | |
| echo "go_issue=" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| fi | |
| echo "go_issue=${GO}" >> "${GITHUB_OUTPUT}" | |
| echo "🧭 Decision: go=${GO:-none}" | |
| jq -r '.reason // empty' "${WORKDIR}/decision.json" | |
| # Label permanently-skipped issues so future scans move past them. | |
| if [[ "${DRY_RUN}" != "true" ]]; then | |
| gh label create 'autofix/skip' --repo "${REPO}" \ | |
| --description 'Not eligible for the scheduled autofix agent' \ | |
| --color 'ededed' 2> /dev/null || true | |
| jq -c '(.skip // [])[] | select(.permanent == true)' "${WORKDIR}/decision.json" \ | |
| | while read -r row; do | |
| NUM="$(jq -r '.number' <<< "${row}")" | |
| if [[ ! "${NUM}" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "⚠️ Invalid skip number: ${NUM}" | |
| continue | |
| fi | |
| if ! grep -qx "${NUM}" <<< "${CANDIDATE_NUMS}"; then | |
| echo "⚠️ Skip issue #${NUM} is not in the candidate list" | |
| continue | |
| fi | |
| echo "🏷️ Skipping #${NUM} permanently: $(jq -r '.reason' <<< "${row}")" | |
| gh issue edit "${NUM}" --repo "${REPO}" --add-label 'autofix/skip' || true | |
| done | |
| fi | |
| - name: 'Claim issue' | |
| id: 'claim' | |
| if: |- | |
| ${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }} | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| run: |- | |
| BODY="🤖 The scheduled autofix agent is picking this issue up. It will attempt to establish the current behavior, implement the requested change, run E2E verification, and open a pull request linked to this issue. If the attempt fails, this claim will be withdrawn so a human can take over. | |
| Maintainers: comment or assign someone to stop future automated attempts, or add the \`autofix/skip\` label." | |
| # The label, not the comment, is what future scans key off to | |
| # avoid double-claiming. | |
| gh label create 'autofix/in-progress' --repo "${REPO}" \ | |
| --description 'The scheduled autofix agent has claimed this issue' \ | |
| --color '1d76db' 2> /dev/null || true | |
| gh label create "${AUTOFIX_APPROVED_LABEL}" --repo "${REPO}" \ | |
| --description 'Maintainer explicitly approved this issue for autonomous autofix' \ | |
| --color '0e8a16' 2> /dev/null || true | |
| if ! gh issue edit "${ISSUE}" --repo "${REPO}" \ | |
| --add-label 'autofix/in-progress'; then | |
| echo "::error::Failed to add autofix/in-progress label on #${ISSUE} before claim comment was posted" | |
| exit 1 | |
| fi | |
| gh issue edit "${ISSUE}" --repo "${REPO}" \ | |
| --remove-label "${AUTOFIX_APPROVED_LABEL}" || true | |
| COMMENT_URL="$(gh issue comment "${ISSUE}" --repo "${REPO}" --body "${BODY}")" | |
| COMMENT_ID="${COMMENT_URL##*-}" | |
| echo "comment_id=${COMMENT_ID}" >> "${GITHUB_OUTPUT}" | |
| echo "📌 Claimed #${ISSUE} (comment ${COMMENT_ID})" | |
| - name: 'Develop fix' | |
| id: 'develop' | |
| if: |- | |
| ${{ steps.decision.outputs.go_issue != '' }} | |
| env: | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}' | |
| OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}' | |
| OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| NO_PROXY: '127.0.0.1,localhost,::1' | |
| QWEN_HOME: '${{ runner.temp }}/qwen-autofix-home' | |
| SETTINGS_JSON: |- | |
| { | |
| "maxSessionTurns": 400, | |
| "coreTools": [ | |
| "read_file", | |
| "read_many_files", | |
| "glob", | |
| "search_file_content", | |
| "write_file", | |
| "run_shell_command(cat)", | |
| "run_shell_command(git add)", | |
| "run_shell_command(git checkout)", | |
| "run_shell_command(git commit)", | |
| "run_shell_command(git diff)", | |
| "run_shell_command(git log)", | |
| "run_shell_command(git status)", | |
| "run_shell_command(git switch)", | |
| "run_shell_command(ls)", | |
| "run_shell_command(mkdir)", | |
| "run_shell_command(npm run build)", | |
| "run_shell_command(npm run typecheck)", | |
| "run_shell_command(npm run lint)", | |
| "run_shell_command(npx vitest)", | |
| "run_shell_command(npm run generate:settings-schema)", | |
| "run_shell_command(pwd)" | |
| ], | |
| "tools": { | |
| "sandbox": "docker" | |
| } | |
| } | |
| run: |- | |
| rm -rf "${QWEN_HOME}" | |
| mkdir -p .qwen "${QWEN_HOME}" | |
| if [[ -z "${OPENAI_API_KEY:-}" ]]; then | |
| echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.' | |
| exit 1 | |
| fi | |
| printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json | |
| rm -f "${WORKDIR}/failure.md" | |
| node .qwen/skills/autofix/scripts/run-agent.mjs \ | |
| --mode develop-issue \ | |
| --issue "${ISSUE}" \ | |
| --workdir "${WORKDIR}" | |
| - name: 'Verification gate' | |
| id: 'verify' | |
| if: |- | |
| ${{ steps.decision.outputs.go_issue != '' }} | |
| env: | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| run: |- | |
| BRANCH="autofix/issue-${ISSUE}" | |
| if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then | |
| echo "❌ Agent wrote failure.md after leaving a dirty workspace:" | |
| git status --short | |
| cat "${WORKDIR}/failure.md" | |
| exit 1 | |
| fi | |
| if [[ -f "${WORKDIR}/failure.md" ]]; then | |
| echo "🛑 Agent aborted intentionally:" | |
| cat "${WORKDIR}/failure.md" | |
| exit 1 | |
| fi | |
| if ! git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then | |
| echo "❌ Expected branch ${BRANCH} does not exist" | |
| exit 1 | |
| fi | |
| git config core.hooksPath /dev/null | |
| git checkout "${BRANCH}" | |
| if git diff --quiet origin/main..."${BRANCH}"; then | |
| echo "❌ Branch has no changes against main" | |
| exit 1 | |
| fi | |
| for f in pr-title.txt pr-body.md e2e-report.md; do | |
| if [[ ! -s "${WORKDIR}/${f}" ]]; then | |
| echo "❌ Missing required output ${f}" | |
| exit 1 | |
| fi | |
| done | |
| echo '🔬 Re-running deterministic checks (independent of the agent)...' | |
| npm run build | |
| npm run typecheck | |
| npm run lint | |
| # Settings-schema freshness gate, shared with the triage-and-address | |
| # verify step so the two copies cannot drift (rationale + the | |
| # generator crash guard live in the script). On failure it writes | |
| # outcome=failed to GITHUB_OUTPUT and exits 1. | |
| # Run the copy staged from the trusted base checkout: a PR branch | |
| # that predates the script does not contain it (bash would exit 127 | |
| # and kill the gate with no outcome), and the gate logic must come | |
| # from the trusted base, not the branch under verification. | |
| bash "${RUNNER_TEMP}/check-settings-schema.sh" | |
| git diff --name-only "origin/main...${BRANCH}" \ | |
| | bash "${RUNNER_TEMP}/check-autofix-contracts.sh" | |
| # Run changed/related tests for the packages this fix touches. | |
| # --changed follows the import graph so transitive breakage is caught. | |
| # Full regression is covered by regular CI on the PR after the push. | |
| # Map each changed file to its OWNING npm workspace via the trusted | |
| # staged resolver, shared with the other verify gate so both resolve | |
| # packages identically. It expands the on-disk root package.json | |
| # workspaces globs (so a workspace the branch ADDS is included) and | |
| # takes each file's longest-prefix workspace — never a flat | |
| # 'packages/<dir>' (ENOENT-crashes on nested packages) nor a fixture | |
| # package.json inside a workspace's src tree (would skip the owning | |
| # workspace's tests). No '|| true': a resolver error (missing node, an | |
| # unreadable manifest) must fail the gate loudly rather than silently | |
| # skip package tests; legitimate no-match input already exits 0 empty. | |
| CHANGED_PKGS="$(git diff --name-only "origin/main...${BRANCH}" \ | |
| | bash "${RUNNER_TEMP}/resolve-owning-packages.sh")" | |
| if [[ -z "${CHANGED_PKGS}" ]]; then | |
| echo 'No package changes detected; skipping package tests.' | |
| else | |
| for p in ${CHANGED_PKGS}; do | |
| if [[ ! -f "${p}/package.json" ]]; then | |
| echo "Skipping ${p}: no package.json." | |
| continue | |
| fi | |
| test_script="$(node -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(pkg.scripts?.test || "");' "${p}/package.json")" | |
| if [[ "${test_script}" != *vitest* ]]; then | |
| echo "Skipping ${p}: test script is not Vitest." | |
| continue | |
| fi | |
| echo "🧪 Testing ${p} (changed files only)..." | |
| npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests | |
| done | |
| fi | |
| - name: 'Show run artifacts' | |
| if: |- | |
| ${{ always() && steps.decision.outputs.go_issue != '' }} | |
| env: | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| run: |- | |
| BRANCH="autofix/issue-${ISSUE}" | |
| if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then | |
| git diff "origin/main...${BRANCH}" > "${WORKDIR}/fix.diff" || true | |
| fi | |
| for f in decision.json pr-title.txt pr-body.md e2e-report.md failure.md fix.diff; do | |
| if [[ -f "${WORKDIR}/${f}" ]]; then | |
| echo "=============== ${f} ===============" | |
| cat "${WORKDIR}/${f}" | |
| echo | |
| fi | |
| done | |
| - name: 'Upload run artifacts' | |
| if: |- | |
| ${{ always() && steps.scan.outputs.has_candidates == 'true' }} | |
| uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 | |
| with: | |
| name: 'autofix-issue-artifacts' | |
| path: '${{ env.WORKDIR }}/' | |
| if-no-files-found: 'ignore' | |
| - name: 'Publish PR' | |
| id: 'publish' | |
| if: |- | |
| ${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }} | |
| env: | |
| # 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. | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| run: |- | |
| MODEL_DISPLAY="${MODEL:-default}" | |
| if [[ -z "${GITHUB_TOKEN}" ]]; then | |
| echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.' | |
| exit 1 | |
| fi | |
| api_error_file="$(mktemp)" | |
| if ! publish_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| echo "CI_DEV_BOT_PAT authenticates as ${publish_actor}" | |
| if [[ "${publish_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as ${publish_actor}; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| BRANCH="autofix/issue-${ISSUE}" | |
| git config --local --unset-all http.https://github.com/.extraheader || true | |
| git config core.hooksPath /dev/null | |
| # Authenticate the push with a one-shot, host-scoped credential | |
| # helper via `git -c`: nothing is written to the reused | |
| # workspace's .git/config (no error path can strand it there), | |
| # argv holds only the literal ${GITHUB_TOKEN} reference, and the | |
| # host scope means it cannot answer a non-GitHub URL. | |
| git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \ | |
| push --no-verify "https://github.com/${REPO}.git" "${BRANCH}" | |
| PR_URL="$(gh pr create --repo "${REPO}" \ | |
| --base main --head "${BRANCH}" \ | |
| --title "$(cat "${WORKDIR}/pr-title.txt")" \ | |
| --body-file "${WORKDIR}/pr-body.md")" | |
| echo "🚀 Opened ${PR_URL}" | |
| # Per AGENTS.md, post the E2E report as a separate PR comment. | |
| { | |
| echo | |
| echo "---" | |
| echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" | |
| } >> "${WORKDIR}/e2e-report.md" | |
| gh pr comment "${PR_URL}" --body-file "${WORKDIR}/e2e-report.md" | |
| - name: 'Report dry-run / failure' | |
| if: |- | |
| ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }} | |
| env: | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| DRY_RUN: '${{ needs.route.outputs.dry_run }}' | |
| OUTCOME: '${{ steps.verify.outputs.outcome }}' | |
| run: |- | |
| SUFFIX='' | |
| [[ "${DRY_RUN}" == "true" ]] && SUFFIX=' (dry-run, nothing pushed)' | |
| { | |
| echo "### Issue autofix${ISSUE:+ #${ISSUE}} — outcome=${OUTCOME:-unknown}${SUFFIX}" | |
| echo | |
| for f in decision.json pr-title.txt pr-body.md e2e-report.md failure.md fix.diff; do | |
| if [[ -s "${WORKDIR}/${f}" ]]; then | |
| echo "**${f}:**" | |
| echo '```' | |
| cat "${WORKDIR}/${f}" | |
| echo '```' | |
| echo | |
| fi | |
| done | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| - name: 'Withdraw claim on failure' | |
| if: |- | |
| ${{ (failure() || cancelled()) && steps.claim.outcome == 'success' }} | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| ISSUE: '${{ steps.decision.outputs.go_issue }}' | |
| COMMENT_ID: '${{ steps.claim.outputs.comment_id }}' | |
| PUBLISH_OUTCOME: '${{ steps.publish.outcome }}' | |
| run: |- | |
| # shellcheck disable=SC2016 | |
| if [[ -f "${WORKDIR}/failure.md" ]]; then | |
| REASON='no further automated attempts will be made on this issue.' | |
| DETAIL="$(head -c 1500 "${WORKDIR}/failure.md")" | |
| LABEL_ARGS=(--remove-label 'autofix/in-progress' --add-label 'autofix/skip') | |
| elif [[ "${PUBLISH_OUTCOME}" == 'failure' ]]; then | |
| REASON='the issue will require the `autofix/approved` label to be re-added before any future automated attempt.' | |
| DETAIL='The agent produced and verified a fix, but publishing the PR failed. Check the Publish PR step logs for the CI_DEV_BOT_PAT actor, git push, PR creation, or PR comment error.' | |
| LABEL_ARGS=(--remove-label 'autofix/in-progress') | |
| else | |
| REASON='the issue will require the `autofix/approved` label to be re-added before any future automated attempt.' | |
| DETAIL='The run failed before producing a verified fix.' | |
| LABEL_ARGS=(--remove-label 'autofix/in-progress') | |
| fi | |
| gh issue edit "${ISSUE}" --repo "${REPO}" "${LABEL_ARGS[@]}" || true | |
| gh issue comment "${ISSUE}" --repo "${REPO}" --body "🤖 Withdrawing the claim above — the automated fix attempt did not succeed; ${REASON} | |
| What the agent found, in case it helps a human contributor: | |
| ${DETAIL}" || true | |
| if [[ -n "${COMMENT_ID}" ]]; then | |
| gh api -X DELETE "/repos/${REPO}/issues/comments/${COMMENT_ID}" || true | |
| fi | |
| # Nothing else removes the per-run WORKDIR; on the persistent pool | |
| # every run would leave its transcripts and decision files behind | |
| # forever. Last step, after every reader including the artifact | |
| # upload. always() covers cancellation too; only a hard runner kill | |
| # abandons the dir, and run_id never repeats — the age sweep in | |
| # Reset autofix workspace reclaims it. | |
| - name: 'Clean up autofix workdir' | |
| if: 'always()' | |
| run: 'rm -rf "${WORKDIR}"' | |
| # =========================================================================== | |
| # TAKEOVER COMMAND — the accepted comment command's side effects are the | |
| # label toggle plus its own acknowledgements: add on an unlabeled PR | |
| # toggles TAKEOVER_LABEL (the resulting pull_request event drives | |
| # engagement exactly like a manual label edit — one mechanism, two entry | |
| # points); add on an already-managed PR posts the re-arm ack (the round | |
| # window anchor); skip-labeled and fork PRs get bilingual refusals instead | |
| # of a toggle. | |
| # =========================================================================== | |
| takeover-command: | |
| needs: 'route' | |
| if: |- | |
| ${{ needs.route.outputs.takeover_cmd != '' }} | |
| # Two quick commands are independent runs; without serialization an older | |
| # /takeover could land its label write AFTER a newer /takeover stop read | |
| # the unlabeled state, leaving the PR engaged against the latest intent. | |
| # Queued (never cancelled) per-PR execution preserves command order. | |
| concurrency: | |
| group: 'qwen-autofix-takeover-cmd-${{ needs.route.outputs.cmd_pr }}' | |
| cancel-in-progress: false | |
| runs-on: 'ubuntu-latest' | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: 'read' | |
| env: | |
| REPO: '${{ github.repository }}' | |
| CMD: '${{ needs.route.outputs.takeover_cmd }}' | |
| PR: '${{ needs.route.outputs.cmd_pr }}' | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| steps: | |
| - name: 'Toggle takeover label' | |
| run: |- | |
| api_error_file="$(mktemp)" | |
| if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as '${bot_actor:-unknown}'; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| # Presence-aware toggle: adding a present label / removing an | |
| # absent one fires no event, so make the no-op explicit instead. | |
| # The same fetch carries isCrossRepository: fork PRs are refused UP | |
| # FRONT — unlike the label path this job runs in issue_comment | |
| # context WITH secrets, so it can explain itself, and refusing here | |
| # keeps the label from ever sticking to a fork PR via the command. | |
| if ! PR_INFO="$(gh pr view "${PR}" --repo "${REPO}" --json labels,isCrossRepository,state,baseRefName,author,maintainerCanModify 2> /dev/null)"; then | |
| echo "::error::could not read PR #${PR}" | |
| exit 1 | |
| fi | |
| # State/base can change while this job sits in its per-PR queue — | |
| # re-verify what the route checked so a stale command cannot label | |
| # a closed or non-main PR. | |
| if [[ "$(jq -r '.state // ""' <<< "${PR_INFO}")" != "OPEN" ]]; then | |
| echo "🧭 takeover command dropped: PR #${PR} is no longer an open PR" | |
| exit 0 | |
| fi | |
| CMD_BASE_REF="$(jq -r '.baseRefName // ""' <<< "${PR_INFO}")" | |
| if [[ "${CMD_BASE_REF}" != "main" ]]; then | |
| if [[ "${CMD}" == 'add' ]]; then | |
| # Refuse OUT LOUD — the silent drop made a /takeover on a | |
| # stacked PR indistinguishable from a lost event. Mirrors the | |
| # label path's base-refused ack, except no label was applied | |
| # here, so the ask is to re-run the command after retargeting | |
| # (not "the label is left in place"). | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: the loop only manages PRs that target `main`, and this one targets `%s`. A stacked PR moves whenever its base branch does, so "new feedback since the last round" and base-conflict resolution are not well defined until the base lands. Retarget this PR to `main` once the base PR merges and re-run `%s` — or take over the base PR instead.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:循环只管理以 `main` 为 base 的 PR,而本 PR 的 base 是 `%s`。堆叠 PR 会随 base 分支移动,因此“自上一轮以来的新反馈”与 base 冲突处理都无法良定义。待 base 的 PR 合入后将本 PR 改为面向 `main` 并重新执行 `%s`;或改为接管 base 那个 PR。\n\n</details>\n\n<!-- takeover-ack base-refused -->' "${CMD_BASE_REF}" "${TAKEOVER_COMMAND}" "${CMD_BASE_REF}" "${TAKEOVER_COMMAND}")" | |
| echo "🧭 takeover command refused: PR #${PR} targets '${CMD_BASE_REF}' not 'main'" | |
| exit 0 | |
| fi | |
| # 'stop' proceeds: removing the label from a non-main PR is | |
| # harmless and matches the latest intent — dropping it here left | |
| # a manually-applied label stuck with no command able to remove | |
| # it (the label path's release ack ignores non-main PRs too). | |
| fi | |
| # Skip wins over takeover EVERYWHERE — including here: engaging or | |
| # re-arming a skip-labeled PR would post an 'engaged' window anchor | |
| # for management that the scans deliberately refuse to perform. | |
| if [[ "${CMD}" == 'add' && "$(jq -r --arg t "${SKIP_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")" == "true" ]]; then | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: this PR carries `%s`, which wins over takeover. Remove `%s` first, then re-run `%s`.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:本 PR 带有 `%s` 标签,其优先级高于接管。请先移除 `%s`,再执行 `%s`。\n\n</details>\n\n<!-- takeover-ack skip-blocked -->' "${SKIP_LABEL}" "${SKIP_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${SKIP_LABEL}" "${TAKEOVER_COMMAND}")" | |
| echo "🧭 takeover command refused: ${SKIP_LABEL} present on #${PR}" | |
| exit 0 | |
| fi | |
| # Fork PRs are manageable when the bot can actually push: the | |
| # author must have granted 'Allow edits from maintainers' | |
| # (org-owned forks cannot — adoption stays the path there), and | |
| # only write+ senders reach this job for forks (fork authors are | |
| # silently dropped at route). Without allow-edits, refuse with the | |
| # actionable ask. Engage-side requirement only: release ('stop') | |
| # is never blocked. | |
| if [[ "${CMD}" == 'add' && "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" \ | |
| && "$(jq -r '.maintainerCanModify // false' <<< "${PR_INFO}")" != "true" ]]; then | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover needs push access to this fork branch: please tick “Allow edits from maintainers” on the PR and re-run `%s`. (Org-owned forks cannot enable it — a maintainer can adopt the PR instead: snapshot the head into an in-repo branch and take that over.)\n\n<details>\n<summary>中文说明</summary>\n\n🚫 托管需要对 fork 分支的推送权限:请在 PR 上勾选 “Allow edits from maintainers” 后重新执行 `%s`。(组织账号的 fork 无法勾选 —— 维护者可改用领养:将 head 快照为本仓库分支后接管。)\n\n</details>\n\n<!-- takeover-ack fork-refused -->' "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")" | |
| echo "🧭 takeover command refused: fork PR #${PR} without maintainer-edit access" | |
| exit 0 | |
| fi | |
| # The fork AUTHOR must hold write+ too (the scan re-checks this on | |
| # every pickup and the address job once more live): engaging a | |
| # below-write fork would stick a label that nothing ever manages — | |
| # a silent ghost engagement with no ack and no explanation. | |
| # Engage-side only; release is never blocked. | |
| if [[ "${CMD}" == 'add' && "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" ]]; then | |
| FORK_PR_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_INFO}")" | |
| FORK_AUTHOR_PERM="$(gh api "repos/${REPO}/collaborators/${FORK_PR_AUTHOR}/permission" --jq '.permission // ""' 2> /dev/null || echo '')" | |
| case "${FORK_AUTHOR_PERM}" in | |
| admin|maintain|write) : ;; | |
| *) | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: fork takeover requires the PR author to hold write access on this repository (author `%s` currently: `%s`). A maintainer can adopt the PR instead: snapshot the head into an in-repo branch, open a new PR (commit authorship is preserved), and take that over.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:fork 托管要求 PR 作者在本仓库持有 write 及以上权限(作者 `%s` 当前为:`%s`)。维护者可改用领养:将 head 快照为本仓库分支并另开 PR(commit 署名保留),再对新 PR 执行接管。\n\n</details>\n\n<!-- takeover-ack fork-refused -->' "${FORK_PR_AUTHOR}" "${FORK_AUTHOR_PERM:-none}" "${FORK_PR_AUTHOR}" "${FORK_AUTHOR_PERM:-none}")" | |
| echo "🧭 takeover command refused: fork PR #${PR} author '${FORK_PR_AUTHOR}' permission='${FORK_AUTHOR_PERM:-none}' below write" | |
| exit 0 | |
| ;; | |
| esac | |
| fi | |
| HAS="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")" | |
| if [[ "${CMD}" == 'add' ]]; then | |
| if [[ "${HAS}" == 'true' ]]; then | |
| # Already managed: repeating the command is the ROUND-COUNTER | |
| # RESET. A fresh engage ack starts a new counting window (only | |
| # markers newer than the latest ack count toward the cap), so | |
| # a PR that exhausted its rounds continues under management — | |
| # no label churn needed. The watermark is untouched: feedback | |
| # already addressed is never replayed. | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🔄 Takeover re-armed: the round counter starts a fresh window (previous rounds no longer count toward the cap); management continues.\n\n<details>\n<summary>中文说明</summary>\n\n🔄 已重新武装:轮次计数开启新窗口(此前轮次不再计入上限),托管继续。\n\n</details>\n\n<!-- takeover-ack engaged -->')" | |
| echo "🔄 re-armed ${TAKEOVER_LABEL} window on #${PR}" | |
| else | |
| gh pr edit "${PR}" --repo "${REPO}" --add-label "${TAKEOVER_LABEL}" | |
| echo "🏷️ applied ${TAKEOVER_LABEL} to #${PR}" | |
| # Ack HERE, not via the pull_request:labeled round-trip: that | |
| # event has been observed to simply not fire (#7999 — the | |
| # author read the silence as failure and removed the label; | |
| # #8002 — no ack for hours), and fork label events could never | |
| # ack at all (they carry no secrets). Every admission gate | |
| # above has already passed, so 'engaged' is truthful for both | |
| # in-repo and fork PRs. The route side suppresses the | |
| # label-path ack when the label sender is the bot, and the | |
| # scan's first-pickup ack dedups against this comment — and | |
| # heals it on the next scan if this post fails, which is why | |
| # a failure here only warns. | |
| FORK_NOTE='' | |
| FORK_NOTE_ZH='' | |
| if [[ "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" ]]; then | |
| FORK_NOTE=' This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes).' | |
| FORK_NOTE_ZH='本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。' | |
| fi | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${FORK_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" \ | |
| || echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it" | |
| fi | |
| else | |
| if [[ "${HAS}" != 'true' ]]; then | |
| echo "ℹ️ #${PR} does not carry ${TAKEOVER_LABEL} — nothing to do" | |
| else | |
| gh pr edit "${PR}" --repo "${REPO}" --remove-label "${TAKEOVER_LABEL}" | |
| echo "🏷️ removed ${TAKEOVER_LABEL} from #${PR}" | |
| # Release ack, direct from the command — the exact mirror of | |
| # the engage side above, for the same reason: the unlabeled | |
| # round-trip is the thing we no longer trust, fork unlabeled | |
| # events can never ack (no secrets), and a non-main release | |
| # never even reaches the ack job. A loud add next to a mute | |
| # stop would re-create the "did it work or did the event get | |
| # lost?" ambiguity on the release side. Variant selection | |
| # mirrors the ack job verbatim (live author + skip label from | |
| # the same PR_INFO the gates used); the route side suppresses | |
| # the unlabeled-path ack when the label sender is the bot. | |
| REL_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_INFO}")" | |
| REL_HAS_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")" | |
| if [[ "${REL_AUTHOR}" == "${AUTOFIX_BOT}" && "${REL_HAS_SKIP}" == "true" ]]; then | |
| REL_BODY="$(printf '👋 Takeover mode ended. This bot-authored PR also carries `%s`, which opts it out of standard bot management entirely — nothing will engage it until that label is removed.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${SKIP_LABEL}")" | |
| elif [[ "${REL_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then | |
| REL_BODY="$(printf '👋 Takeover mode ended: the raised round cap no longer applies. This is a bot-authored PR, so STANDARD bot management continues under the strict cap (apply `%s` to opt it out entirely). Re-apply `%s` (or comment `%s`) for the raised cap again.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" | |
| else | |
| REL_BODY="$(printf '👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply `%s` (or comment `%s`) to re-engage.\n\n<details>\n<summary>中文说明</summary>\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n</details>\n\n<!-- takeover-ack released -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" | |
| fi | |
| gh pr comment "${PR}" --repo "${REPO}" --body "${REL_BODY}" \ | |
| || echo "::warning::release ack comment failed on #${PR}" | |
| fi | |
| fi | |
| # =========================================================================== | |
| # TAKEOVER ACK — visible confirmation when a maintainer engages or releases | |
| # a PR via the takeover label. Manual label toggles are explicit user | |
| # actions, so every one acks (no dedup wanted). Command-driven toggles are | |
| # acked by takeover-command itself in BOTH directions — the label event has | |
| # been observed to not fire at all (#7999, #8002), so those acks cannot | |
| # depend on this round-trip — and the route suppresses this job for them | |
| # (label sender is the bot). In-repo PRs only reach this job. | |
| # =========================================================================== | |
| # Re-arm a stranded PR without deleting anything. Recovery previously meant | |
| # `gh api -X DELETE` on the bot's own autofix-eval marker comment: raw API | |
| # access, an erased audit trail, and undiscoverable unless you had read the | |
| # workflow. This posts ONE marker instead — the scan then re-reads the | |
| # feedback (the marker releases the watermark those older markers held) and | |
| # the round counter resets, because the marker also opens a fresh counting | |
| # window exactly like an engage ack. | |
| retry-command: | |
| needs: 'route' | |
| if: |- | |
| ${{ needs.route.outputs.retry_pr != '' }} | |
| # Queued (never cancelled) per PR so two quick /retry comments cannot | |
| # interleave their marker writes. | |
| concurrency: | |
| group: 'qwen-autofix-retry-cmd-${{ needs.route.outputs.retry_pr }}' | |
| cancel-in-progress: false | |
| runs-on: 'ubuntu-latest' | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: 'read' | |
| env: | |
| REPO: '${{ github.repository }}' | |
| PR: '${{ needs.route.outputs.retry_pr }}' | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| steps: | |
| - name: 'Post re-arm marker' | |
| run: |- | |
| # The marker only counts when the AUTOFIX_BOT authored it (both the | |
| # scan and the address-side recheck filter markers by that login), so | |
| # a mis-scoped PAT would post a comment that silently does nothing. | |
| api_error_file="$(mktemp)" | |
| if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as '${bot_actor:-unknown}'; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '\U0001F504 AutoFix re-armed. The next scan re-reads this PR'"'"'s feedback from the start and the round counter resets. Nothing was deleted — this marker supersedes the evaluation markers above it.\n\n<details>\n<summary>中文说明</summary>\n\n\U0001F504 已重新武装 AutoFix。下一次扫描会从头重新读取本 PR 的反馈,轮次计数也已重置。未删除任何内容 —— 本标记使其上方的评估标记失效。\n\n</details>\n\n<!-- autofix-rearm -->')" | |
| echo "🔄 re-armed PR #${PR}" | |
| takeover-ack: | |
| needs: 'route' | |
| if: |- | |
| ${{ needs.route.outputs.takeover_ack != '' }} | |
| runs-on: 'ubuntu-latest' | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: 'read' | |
| env: | |
| REPO: '${{ github.repository }}' | |
| ACK: '${{ needs.route.outputs.takeover_ack }}' | |
| PR: '${{ needs.route.outputs.ack_pr }}' | |
| # The base the ROUTE saw when it refused — naming a live re-read here | |
| # could report 'main' for a refusal that was decided against something | |
| # else, so the message stays tied to the decision it explains. | |
| ACK_BASE: '${{ needs.route.outputs.ack_base }}' | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| steps: | |
| - name: 'Acknowledge takeover state change' | |
| run: |- | |
| api_error_file="$(mktemp)" | |
| if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as '${bot_actor:-unknown}'; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| # Bilingual with COLLAPSED Chinese (project convention), built via | |
| # printf so no workflow indentation leaks into the markdown (4+ | |
| # leading spaces would render the marker line as a code block). | |
| # Live label/author state decides WHAT to acknowledge: a skip label | |
| # vetoes the engagement (skip wins — no engaged anchor for | |
| # management the scans refuse), and a release on a BOT-authored PR | |
| # must not claim disengagement — standard bot management continues, | |
| # only takeover mode (raised cap) ends. | |
| # Fail CLOSED like the sibling takeover-command job: empty metadata | |
| # here would default HAS_SKIP to false and post a wrong "engaged" | |
| # ack on a skip-labeled PR during a transient API failure. A red | |
| # ack job posts nothing — engagement itself is scan-driven and | |
| # unaffected. | |
| # A base refusal needs no live state — it is decided entirely by the | |
| # route — so it does NOT ride on this read. Making the one ack whose | |
| # whole purpose is "say why nothing happened" depend on an unrelated | |
| # API call would reintroduce the silence it exists to remove. | |
| HAS_SKIP='' | |
| PR_AUTHOR_LIVE='' | |
| if [[ "${ACK}" != 'base-refused' ]]; then | |
| if ! PR_STATE_INFO="$(gh pr view "${PR}" --repo "${REPO}" --json labels,author 2> /dev/null)"; then | |
| echo "::error::could not read PR #${PR} state for takeover ack" | |
| exit 1 | |
| fi | |
| HAS_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_STATE_INFO}")" | |
| PR_AUTHOR_LIVE="$(jq -r '.author.login // ""' <<< "${PR_STATE_INFO}")" | |
| fi | |
| if [[ "${ACK}" == 'base-refused' ]]; then | |
| BODY="$(printf '🚫 Takeover not engaged: the loop only manages PRs that target `main`, and this one targets `%s`. A stacked PR moves whenever its base branch does, so "new feedback since the last round" and base-conflict resolution are not well defined until the base lands. Two ways forward: retarget this PR to `main` once the base PR merges — the `%s` label is left in place and the scan lists by label, so the next scan engages it with no re-labelling — or take over the base PR instead.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:循环只管理以 `main` 为 base 的 PR,而本 PR 的 base 是 `%s`。堆叠 PR 会随 base 分支移动,因此“自上一轮以来的新反馈”与 base 冲突处理都无法良定义。两条路:待 base 的 PR 合入后把本 PR 改为面向 `main` —— `%s` 标签予以保留,扫描按标签枚举,下一次扫描即会自动接管,无需重新打标签;或改为接管 base 那个 PR。\n\n</details>\n\n<!-- takeover-ack base-refused -->' "${ACK_BASE}" "${TAKEOVER_LABEL}" "${ACK_BASE}" "${TAKEOVER_LABEL}")" | |
| elif [[ "${ACK}" == 'engaged' && "${HAS_SKIP}" == "true" ]]; then | |
| BODY="$(printf '🚫 Takeover label applied but NOT engaged: this PR carries `%s`, which wins over takeover — the loop will not manage it. Remove `%s` to engage.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 已打接管标签但未生效:本 PR 带有 `%s`,其优先级高于接管,循环不会介入。移除 `%s` 后才会接管。\n\n</details>\n\n<!-- takeover-ack skip-blocked -->' "${SKIP_LABEL}" "${SKIP_LABEL}" "${SKIP_LABEL}" "${SKIP_LABEL}")" | |
| elif [[ "${ACK}" == 'engaged' ]]; then | |
| BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" | |
| elif [[ "${PR_AUTHOR_LIVE}" == "${AUTOFIX_BOT}" && "${HAS_SKIP}" == "true" ]]; then | |
| BODY="$(printf '👋 Takeover mode ended. This bot-authored PR also carries `%s`, which opts it out of standard bot management entirely — nothing will engage it until that label is removed.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${SKIP_LABEL}")" | |
| elif [[ "${PR_AUTHOR_LIVE}" == "${AUTOFIX_BOT}" ]]; then | |
| BODY="$(printf '👋 Takeover mode ended: the raised round cap no longer applies. This is a bot-authored PR, so STANDARD bot management continues under the strict cap (apply `%s` to opt it out entirely). Re-apply `%s` (or comment `%s`) for the raised cap again.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" | |
| else | |
| BODY="$(printf '👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply `%s` (or comment `%s`) to re-engage.\n\n<details>\n<summary>中文说明</summary>\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n</details>\n\n<!-- takeover-ack released -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" | |
| fi | |
| gh pr comment "${PR}" --repo "${REPO}" --body "${BODY}" | |
| # =========================================================================== | |
| # 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 — | |
| # its single write is the identity-verified, once-per-window cap notice. | |
| # =========================================================================== | |
| review-scan: | |
| needs: 'route' | |
| if: |- | |
| ${{ needs.route.outputs.do_review == 'true' }} | |
| runs-on: 'ubuntu-latest' | |
| timeout-minutes: 15 | |
| # A forced scan can write the same status comment as review-address. | |
| # Share its per-PR lock so neither writer can erase the other's state. | |
| # Keep the predicate as narrow as the job's own `if:` — concurrency is | |
| # evaluated BEFORE it, so without the do_review conjunct a dispatch with | |
| # `phase: issue` + `pr_number: N` (route emits pr_number unconditionally) | |
| # would park this skipped job in that PR's shared slot behind a 300-minute | |
| # address round, stalling the issue phase that `needs` it. | |
| concurrency: | |
| group: "qwen-pr-head-write-${{ needs.route.outputs.do_review == 'true' && needs.route.outputs.pr_number || github.run_id }}" | |
| cancel-in-progress: false | |
| outputs: | |
| targets: '${{ steps.scan.outputs.targets }}' | |
| has_targets: '${{ steps.scan.outputs.has_targets }}' | |
| env: | |
| REPO: '${{ github.repository }}' | |
| steps: | |
| - name: 'Scan for PRs with new feedback' | |
| id: 'scan' | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| FORCED_PR: '${{ needs.route.outputs.pr_number }}' | |
| DRY_RUN: '${{ needs.route.outputs.dry_run }}' | |
| EVENT_NAME: '${{ github.event_name }}' | |
| run: |- | |
| # Fleet visibility: every per-PR decision below also records a row so | |
| # the run summary shows the WHOLE managed fleet in one table. | |
| # Reconstructing this by hand (list bot PRs, regex each one's eval | |
| # markers, cross-check checks and fork state) was the only way to see | |
| # a stall, so stalls stayed invisible until someone went looking. | |
| FLEET_FILE="$(mktemp)" | |
| trap 'rm -f "${FLEET_FILE}"' EXIT | |
| fleet_row() { | |
| printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "${FLEET_FILE}" | |
| } | |
| WORKDIR="$(mktemp -d)" | |
| read_forced_pr_meta() { | |
| local attempt meta | |
| for attempt in 1 2 3; do | |
| if meta="$(gh pr view "${FORCED_PR}" --repo "${REPO}" \ | |
| --json number,state,author,headRefName,isCrossRepository,baseRefName,labels,maintainerCanModify 2> /dev/null)" \ | |
| && jq -e 'type == "object" | |
| and (.number | type == "number") | |
| and (.state | type == "string") | |
| and (.author.login | type == "string") | |
| and (.headRefName | type == "string") | |
| and (.baseRefName | type == "string") | |
| and (.isCrossRepository | type == "boolean") | |
| and (.labels | type == "array") | |
| and (.maintainerCanModify | type == "boolean")' > /dev/null <<< "${meta}"; then | |
| printf '%s' "${meta}" | |
| return 0 | |
| fi | |
| echo "::warning::Forced PR #${FORCED_PR} metadata lookup failed (attempt ${attempt}/3)" >&2 | |
| [[ "${attempt}" -lt 3 ]] && sleep "${attempt}" | |
| done | |
| return 1 | |
| } | |
| # 'none' and HTTP 404 are DEFINITIVE answers, not lookup failures. | |
| # GitHub returns 200 with permission 'none' for logins that exist but | |
| # hold nothing here (bot-type logins such as dependabot[bot], and org | |
| # logins), and 404 for logins that do not exist or are empty. Both | |
| # mean "no write access" — the routine rejection this gate is for. | |
| # Retrying them would burn 3 API calls plus back-off per candidate per | |
| # scheduled tick, forever, and strand the caller on | |
| # 'permission_lookup_failed': a red forced run (exit 1) whose blocked | |
| # comment promises "a later scheduled scan will retry" — a retry that | |
| # can never succeed — while the actionable "grant the fork author | |
| # write access" guidance behind author_permission_* stays unreachable. | |
| # Only genuinely transient answers (5xx, network, auth) retry. | |
| read_live_permission() { | |
| local login="$1" attempt permission err result='' | |
| # An empty login can only 404; skip the call and answer terminally. | |
| if [[ -z "${login}" ]]; then | |
| printf 'none' | |
| return 0 | |
| fi | |
| err="$(mktemp)" | |
| for attempt in 1 2 3; do | |
| if permission="$(gh api "repos/${REPO}/collaborators/${login}/permission" --jq '.permission // ""' 2> "${err}")" \ | |
| && [[ "${permission}" =~ ^(admin|maintain|write|triage|read|none)$ ]]; then | |
| result="${permission}" | |
| break | |
| fi | |
| if grep -q 'HTTP 404' "${err}"; then | |
| result='none' | |
| break | |
| fi | |
| # Surface gh's own diagnosis instead of discarding it: a rate | |
| # limit, an expired PAT and a 5xx all look identical otherwise. | |
| echo "::warning::Permission lookup failed for ${login} (attempt ${attempt}/3): $(tr '\n' ' ' < "${err}")" >&2 | |
| [[ "${attempt}" -lt 3 ]] && sleep "${attempt}" | |
| done | |
| rm -f "${err}" | |
| [[ -n "${result}" ]] || return 1 | |
| printf '%s' "${result}" | |
| } | |
| forced_admission_reason() { | |
| jq -r --arg ab "${AUTOFIX_BOT}" --arg take "${TAKEOVER_LABEL}" --arg skip "${SKIP_LABEL}" ' | |
| if (.state // "") != "OPEN" then "not_open" | |
| elif (.baseRefName // "") != "main" then "wrong_base" | |
| elif ([.labels[]?.name] | index($skip) != null) then "skip_label" | |
| elif ((((.author.login // "") == $ab) or ([.labels[]?.name] | index($take) != null)) | not) then "unmanaged_author" | |
| elif (.isCrossRepository == true) and (.maintainerCanModify != true) then "maintainer_edits_disabled" | |
| elif (((.isCrossRepository == true) or (.isCrossRepository == false)) | not) then "cross_repo_state_missing" | |
| else "eligible" | |
| end' | |
| } | |
| report_forced_takeover_blocked() { | |
| local reason="$1" actor status_ids status_id body attempt status_lookup_ok next_en next_zh err | |
| [[ "${DRY_RUN}" == 'true' ]] && return 0 | |
| [[ "$(jq -r --arg ab "${AUTOFIX_BOT}" --arg take "${TAKEOVER_LABEL}" ' | |
| ((.author.login // "") == $ab) or ([.labels[]?.name] | index($take) != null) | |
| ' <<< "${META}")" == 'true' ]] || return 0 | |
| case "${reason}" in | |
| permission_lookup_failed|author_permission_*|maintainer_edits_disabled|cross_repo_state_missing) ;; | |
| *) return 0 ;; | |
| esac | |
| actor='' | |
| for attempt in 1 2 3; do | |
| if actor="$(gh api user --jq '.login' 2> /dev/null)" && [[ -n "${actor}" ]]; then | |
| break | |
| fi | |
| echo "::warning::PAT identity lookup failed (attempt ${attempt}/3)" >&2 | |
| [[ "${attempt}" -lt 3 ]] && sleep "${attempt}" | |
| done | |
| if [[ "${actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::warning::Blocked takeover status skipped: PAT authenticates as '${actor:-unknown}'" >&2 | |
| return 1 | |
| fi | |
| if [[ "${reason}" == 'maintainer_edits_disabled' ]]; then | |
| next_en='Re-enable maintainer edits on the fork PR to resume takeover.' | |
| next_zh='请在 fork PR 上重新允许 maintainer edits,以恢复 takeover。' | |
| elif [[ "${reason}" == author_permission_* ]]; then | |
| next_en='Grant the fork author write access, or remove the autofix/takeover label, to resume takeover.' | |
| next_zh='请授予 fork 作者 write 权限,或移除 autofix/takeover 标签,以恢复 takeover。' | |
| else | |
| next_en='A later scheduled scan will retry without advancing the feedback watermark.' | |
| next_zh='后续定时扫描会重试,本次不会推进反馈水位。' | |
| fi | |
| # Every other status writer resolves its run link from | |
| # github.server_url / GITHUB_SERVER_URL. Hardcoding github.com here | |
| # would make the one link this message exists to surface the only | |
| # broken one on a GHES or proxied host. | |
| body="$(printf '<!-- autofix-status -->\n\n⛔ **AutoFix blocked** — takeover admission stopped at `%s`, so no work was started. [View run](%s/%s/actions/runs/%s). %s\n\n<details>\n<summary>中文说明</summary>\n\n⛔ **AutoFix 已阻塞** —— takeover 准入停在 `%s`,因此本轮未开始处理。[查看运行](%s/%s/actions/runs/%s)。%s\n\n</details>' \ | |
| "${reason}" "${GITHUB_SERVER_URL}" "${REPO}" "${GITHUB_RUN_ID}" "${next_en}" \ | |
| "${reason}" "${GITHUB_SERVER_URL}" "${REPO}" "${GITHUB_RUN_ID}" "${next_zh}")" | |
| status_ids='' | |
| status_lookup_ok=false | |
| err="$(mktemp)" | |
| # Same filter as the sibling upsert in 'Post autofix status comment', | |
| # including its two guards: `// ""` so a single comment with a null | |
| # body cannot abort the whole program (jq exits 5, all three | |
| # attempts fail, and the run reds out WITHOUT posting the very | |
| # status it exists to post), and --arg so a repo-configured | |
| # AUTOFIX_BOT_LOGIN containing " or \ is a mismatch instead of a jq | |
| # parse error. Stays an inline id stream into `tail -1` — it never | |
| # lands in a WORKDIR json file, so the WORKDIR page normalizer | |
| # (add-with-empty-default) must NOT be applied here: it would wrap | |
| # the id stream in an array and break the tail-1 consumer. | |
| # pipefail is set LOCALLY here rather than relied on: this `if` | |
| # must test gh's status, not jq's. A gh failure carrying an HTTP | |
| # status prints the error body to stdout, so jq errors out and the | |
| # retry fires — but a CONNECTION-level failure (TCP reset, TLS | |
| # abort, DNS blip) leaves stdout EMPTY, and `jq -rs` then prints | |
| # nothing and exits 0. Without pipefail that reads as success on | |
| # nothing read: status_lookup_ok=true, the empty id takes the | |
| # writer down the "no status comment yet" branch, and it posts a | |
| # DUPLICATE ⛔ blocked comment beside the stale ✅ one — the exact | |
| # two-status state this function exists to prevent — on a green | |
| # run. `defaults.run.shell: bash` already gives every step in this | |
| # file `-eo pipefail`, so this is redundant today; it is also the | |
| # only guard that survives that default changing or this helper | |
| # being lifted into a step that sets its own options. | |
| for attempt in 1 2 3; do | |
| if status_ids="$(set -o pipefail; gh api "repos/${REPO}/issues/${FORCED_PR}/comments" --paginate 2> "${err}" | | |
| jq -rs --arg ab "${AUTOFIX_BOT}" --arg m '<!-- autofix-status -->' \ | |
| '.[][] | select((.user.login // "") == $ab) | select((.body // "") | contains($m)) | .id')"; then | |
| status_lookup_ok=true | |
| break | |
| fi | |
| # Surface gh's own diagnosis instead of discarding it, exactly as | |
| # read_live_permission does: a rate limit, an expired PAT and a | |
| # 5xx are indistinguishable from 'attempt 3/3' alone. | |
| echo "::warning::Takeover status lookup failed for #${FORCED_PR} (attempt ${attempt}/3): $(tr '\n' ' ' < "${err}")" >&2 | |
| [[ "${attempt}" -lt 3 ]] && sleep "${attempt}" | |
| done | |
| rm -f "${err}" | |
| if [[ "${status_lookup_ok}" != 'true' ]]; then | |
| echo "::warning::Failed to read takeover status comments for #${FORCED_PR}" >&2 | |
| return 1 | |
| fi | |
| status_id="$(tail -1 <<< "${status_ids}")" | |
| if [[ -n "${status_id}" ]]; then | |
| for attempt in 1 2 3; do | |
| if gh api --method PATCH "repos/${REPO}/issues/comments/${status_id}" -f body="${body}" > /dev/null; then | |
| return 0 | |
| fi | |
| echo "::warning::Failed to update blocked takeover status for #${FORCED_PR} (attempt ${attempt}/3)" >&2 | |
| [[ "${attempt}" -lt 3 ]] && sleep "${attempt}" | |
| done | |
| else | |
| for attempt in 1 2 3; do | |
| if gh pr comment "${FORCED_PR}" --repo "${REPO}" --body "${body}" > /dev/null; then | |
| return 0 | |
| fi | |
| echo "::warning::Failed to post blocked takeover status for #${FORCED_PR} (attempt ${attempt}/3)" >&2 | |
| [[ "${attempt}" -lt 3 ]] && sleep "${attempt}" | |
| done | |
| fi | |
| return 1 | |
| } | |
| # Candidate PRs: open, same-repo, targeting main, and either | |
| # authored by the dev-bot or opted in via TAKEOVER_LABEL. A PR | |
| # carrying SKIP_LABEL is excluded everywhere — skip wins over | |
| # takeover when both are present. A forced PR must still pass all | |
| # these checks. NOTE `.isCrossRepository == false` (fail-closed on a | |
| # missing field), never a `// true` default piped through `not`: | |
| # jq's // treats false as empty, so that form is false for EVERY | |
| # input and silently green-no-op'd all forced dispatches. | |
| if [[ -n "${FORCED_PR}" ]]; then | |
| if ! META="$(read_forced_pr_meta)"; then | |
| echo "::error::Forced PR #${FORCED_PR} admission blocked: metadata_fetch_failed" | |
| echo "targets=[]" >> "${GITHUB_OUTPUT}" | |
| echo "has_targets=false" >> "${GITHUB_OUTPUT}" | |
| exit 1 | |
| fi | |
| # Same admission as the scheduled scan below. In-repo PRs fail | |
| # CLOSED on a missing isCrossRepository field (`.isCrossRepository | |
| # == false`, never a `// true | not` default — jq's // treats false | |
| # as empty, so that form is false for EVERY input and silently | |
| # green-no-op'd all forced dispatches). Fork PRs are admitted under | |
| # the scan's OWN fork rules (allow-edits on; the live write+ author | |
| # gate runs in the shell case just below, mirroring the scan's | |
| # per-candidate permission call) so the real-time route's fork | |
| # pickup is not silently discarded here. | |
| ADMISSION_REASON="$(forced_admission_reason <<< "${META}")" | |
| # Fork only: the author must hold write+ RIGHT NOW (the same | |
| # live-privilege rule the scan applies per candidate and | |
| # review-address re-checks before pushing). In-repo PRs are gated | |
| # by author/label alone. | |
| if [[ "${ADMISSION_REASON}" == 'eligible' && "$(jq -r '.isCrossRepository == true' <<< "${META}")" == 'true' ]]; then | |
| FORK_AUTHOR="$(jq -r '.author.login // ""' <<< "${META}")" | |
| if ! FPERM="$(read_live_permission "${FORK_AUTHOR}")"; then | |
| ADMISSION_REASON='permission_lookup_failed' | |
| report_forced_takeover_blocked "${ADMISSION_REASON}" \ | |
| || echo "::error::Forced PR #${FORCED_PR} blocked status update failed" | |
| echo "::error::Forced PR #${FORCED_PR} admission blocked: ${ADMISSION_REASON}" | |
| echo "targets=[]" >> "${GITHUB_OUTPUT}" | |
| echo "has_targets=false" >> "${GITHUB_OUTPUT}" | |
| exit 1 | |
| fi | |
| case "${FPERM}" in | |
| admin|maintain|write) | |
| echo "🌿 forced fork PR #${FORCED_PR} admitted (author ${FORK_AUTHOR}=${FPERM})" | |
| ;; | |
| *) | |
| ADMISSION_REASON="author_permission_${FPERM:-none}" | |
| echo "🧭 forced fork PR #${FORCED_PR} rejected: ${ADMISSION_REASON}" | |
| ;; | |
| esac | |
| fi | |
| if [[ "${ADMISSION_REASON}" != 'eligible' ]]; then | |
| if ! report_forced_takeover_blocked "${ADMISSION_REASON}"; then | |
| echo "::error::Forced PR #${FORCED_PR} blocked status update failed" | |
| echo "targets=[]" >> "${GITHUB_OUTPUT}" | |
| echo "has_targets=false" >> "${GITHUB_OUTPUT}" | |
| exit 1 | |
| fi | |
| echo "❌ Forced PR #${FORCED_PR} rejected: ${ADMISSION_REASON}" | |
| 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}" \ | |
| --base main \ | |
| --limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/bot-prs.json" | |
| gh pr list --repo "${REPO}" --state open --label "${TAKEOVER_LABEL}" \ | |
| --base main \ | |
| --limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/takeover-prs.json" | |
| # Skip-labeled PRs are excluded HERE, not only at the address | |
| # gate: that gate discards without writing a marker, so the | |
| # watermark never advances and an unfiltered scan would re-emit | |
| # the PR (checkout, npm ci, build) every tick forever. | |
| # Rotating start offset (changes every ~10 minutes): a fixed | |
| # newest-first order plus the inspection budget would starve the | |
| # oldest tail FOREVER once the pool exceeds the budget; rotation | |
| # guarantees every candidate is reached within pool/budget scans. | |
| ROT_OFF="$(( ($(date -u +%s) / 600) % 97 ))" | |
| CANDIDATES="$(jq -rs --arg skip "${SKIP_LABEL}" --argjson off "${ROT_OFF}" \ | |
| 'add | |
| | unique_by(.number) | |
| | sort_by(-.number) | |
| | map(select(.isCrossRepository == false)) | |
| | map(select([.labels[]?.name] | index($skip) | not)) | |
| | (if length == 0 then . else (($off % length) as $o | .[$o:] + .[:$o]) end) | |
| | .[] | |
| | .number' \ | |
| "${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json")" | |
| # FORK PRs are admitted per candidate: the author must hold write+ | |
| # RIGHT NOW (the same live-privilege rule as the comment command) | |
| # and the PR must allow maintainer edits (or the bot cannot push). | |
| # Two sources, unioned: takeover-LABELED forks (any eligible author, | |
| # explicit opt-in) AND the bot's OWN forks (bot-prs.json is | |
| # --author AUTOFIX_BOT) — a fork the bot itself opened is its own | |
| # generated work, trust-equal to an in-repo bot PR, so it needs no | |
| # label (autofix/skip still opts it out). Rare set — one permission | |
| # call each; the write+ check below still gates every candidate. | |
| # Appended after the rotated in-repo list: forks sit outside the | |
| # anti-starvation rotation, which only bites once in-repo | |
| # candidates alone exhaust the inspection budget. | |
| while IFS=$'\t' read -r FPR FAUTHOR; do | |
| [[ -z "${FPR}" ]] && continue | |
| if ! FPERM="$(read_live_permission "${FAUTHOR}")"; then | |
| echo "::warning::Fork takeover candidate #${FPR} blocked: permission_lookup_failed" | |
| fleet_row "${FPR}" 'blocked' 'permission_lookup_failed' | |
| continue | |
| fi | |
| case "${FPERM}" in | |
| admin|maintain|write) | |
| echo "🌿 fork takeover candidate #${FPR} admitted (author ${FAUTHOR}=${FPERM})" | |
| CANDIDATES="${CANDIDATES} ${FPR}" | |
| ;; | |
| *) | |
| echo "🧭 fork takeover candidate #${FPR} skipped: author ${FAUTHOR} permission='${FPERM:-none}' below write" | |
| fleet_row "${FPR}" 'blocked' "author_permission_${FPERM:-none}" | |
| ;; | |
| esac | |
| done < <(jq -rs --arg skip "${SKIP_LABEL}" ' | |
| add | unique_by(.number) | |
| | .[] | select(.isCrossRepository == true) | |
| | select(.maintainerCanModify == true) | |
| | select([.labels[]?.name] | index($skip) | not) | |
| | [(.number | tostring), (.author.login // "")] | @tsv' \ | |
| "${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json") | |
| fi | |
| # Pending-check staleness bound (invariant across candidate PRs, computed | |
| # once): ignore a check stuck far past any legitimate runtime. The bound | |
| # must sit ABOVE real check durations here — review-pr can take ~50m and | |
| # a review-address JOB runs up to its 300-minute cap — so an active run | |
| # keeps blocking and is never aged out mid-flight (which would enqueue | |
| # the PR against a live check and double-process the feedback). 330 holds | |
| # a 30-minute margin over that cap. | |
| PENDING_STALE_MIN=330 | |
| PENDING_CUTOFF="$(date -u -d "${PENDING_STALE_MIN} minutes ago" +%Y-%m-%dT%H:%M:%SZ)" | |
| # Repetition-guard cutoff for the stale-base update marker (invariant | |
| # across candidate PRs, computed once — same reasoning as | |
| # PENDING_CUTOFF above). A marker newer than this bounds re-updates | |
| # to once per 2 hours (CI takes ~40 min; main moves ~13 min). | |
| BASE_UPDATE_CUTOFF="$(date -u -d '120 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" | |
| # Base of the auto-update-stale-base decision below. A PR can be red | |
| # purely because it merged a main that was BROKEN at the time and has | |
| # since been FIXED — observed repeatedly (a web-shell TS break, an | |
| # agent-registry test) stranding healthy PRs on a failure that has | |
| # nothing to do with them. GitHub's "Update branch" merges current | |
| # main in and re-runs CI, which clears it. We do that automatically | |
| # only when the SAME failing check also passed for the PR that produced | |
| # current main (MAIN_GREEN_CHECKS) — a necessary-but-NOT-sufficient | |
| # signal, NOT proof that main is healthy. | |
| # | |
| # MAIN_GREEN_CHECKS is sourced from the last-merged PR's PRE-MERGE | |
| # check-runs, which ran against that PR merged with main-as-of-then — | |
| # never the tree now on main (ci.yml has no push trigger, so main's | |
| # squash commits carry no check-runs to read). main breaks here by | |
| # SEMANTIC CONFLICT: two PRs green apart but broken together. In exactly | |
| # that state the last-merged PR is green, this signal reads green, and | |
| # the update would merge a currently-broken main into a healthy PR. The | |
| # signal also inherits the last PR's matrix shape (a SKIPPED platform | |
| # job is absent, so a PR stranded on it is never unstuck — fail-safe, | |
| # but non-deterministic). The blast radius stays recoverable, not zero: | |
| # the merge (not rebase) is revertible, a marker bounds re-updates to | |
| # once per 2h, and the CAS (expected_head_sha) rejects a concurrent | |
| # push. A re-enabled merge queue would let us source this from a | |
| # genuinely validated merged tree instead: ci.yml DOES have a | |
| # merge_group trigger, so a merged tree's check-runs would land where | |
| # we could read them. | |
| # | |
| # Fetch main's head and that check-name set ONCE per scan: resolve | |
| # main's head to the PR that produced it and read check-runs from that | |
| # PR's head SHA. | |
| MAIN_HEAD="$(gh api "repos/${REPO}/commits/main" --jq '.sha' 2> /dev/null || echo '')" | |
| MAIN_GREEN_CHECKS='[]' | |
| if [[ -n "${MAIN_HEAD}" ]]; then | |
| MAIN_PR_HEAD="$(gh api "repos/${REPO}/commits/${MAIN_HEAD}/pulls" \ | |
| --jq '.[0].head.sha // ""' 2> /dev/null || echo '')" | |
| if [[ -n "${MAIN_PR_HEAD}" ]]; then | |
| MAIN_GREEN_CHECKS="$(gh api --paginate "repos/${REPO}/commits/${MAIN_PR_HEAD}/check-runs" \ | |
| --jq '[.check_runs[] | select(.conclusion == "success") | .name]' 2> /dev/null \ | |
| | jq -c -s 'add // [] | unique')" || MAIN_GREEN_CHECKS='[]' | |
| fi | |
| fi | |
| # PRs whose review-address is already RUNNING OR QUEUED in any live | |
| # autofix run must not be re-targeted. Schedule/dispatch runs execute | |
| # against main's SHA, so their matrix jobs never appear in the PR's | |
| # statusCheckRollup — and a fanned-out matrix holds queued jobs well | |
| # past a 10-minute tick, so without this the next scan re-emits the | |
| # same PRs and the per-PR address groups accumulate duplicates that | |
| # later replay stale watermarks. The status filter is SERVER-side: a | |
| # client-side filter over the N newest runs loses a long-lived | |
| # fanned-out run once cron traffic pushes it past the window, and | |
| # its queued PRs silently stop looking busy. Filtered this way the | |
| # limit applies to LIVE runs only (at most a handful), and one | |
| # jobs-view per live run stays cheap. | |
| BUSY_PRS=' ' | |
| while IFS= read -r LIVE_RUN; do | |
| [[ -z "${LIVE_RUN}" ]] && continue | |
| while IFS= read -r BUSY; do | |
| [[ -n "${BUSY}" ]] && BUSY_PRS="${BUSY_PRS}${BUSY} " | |
| done < <(gh run view "${LIVE_RUN}" --repo "${REPO}" --json jobs \ | |
| --jq '.jobs[] | select(.status != "completed") | .name | capture("^review-address \\((?<pr>[0-9]+),") | .pr' 2> /dev/null) | |
| done < <( | |
| for LIVE_STATUS in in_progress queued; do | |
| # || true: one status query failing must not hide the other. A | |
| # DOUBLE failure yields an empty set — deliberately fail-open: | |
| # this skip is an optimization, and the address-side live-marker | |
| # revalidation is the correctness gate. If that revalidation is | |
| # ever removed, this read must become fail-closed instead. | |
| gh run list --repo "${REPO}" --workflow qwen-autofix.yml \ | |
| --status "${LIVE_STATUS}" --limit 50 --json databaseId \ | |
| --jq '.[].databaseId' 2> /dev/null || true | |
| done | sort -u | |
| ) | |
| [[ "${BUSY_PRS}" != ' ' ]] && echo "🚧 address in flight/queued for PR(s):${BUSY_PRS}" | |
| # Idle backoff, from the list's own updatedAt (no API call): a | |
| # candidate with no activity for >24h is inspected on about one | |
| # scan in four instead of every one. The pool doubled in two | |
| # days (28 takeover PRs, 8 of them idle in "nothing new" state | |
| # for 10+ hours), and every idle inspection costs a unit of the | |
| # SHARED MAX_CANDIDATE_INSPECTIONS budget plus a slice of the | |
| # serial API walk over the candidate list. The win is small: a | |
| # few fewer gh round-trips per scan (~2-3 of the pool) and less | |
| # rate-limit pressure. It does NOT recover the job's queue or | |
| # startup latency, which dwarfed the walk in the #8002 | |
| # measurement that motivated this. Idle PRs never reach the | |
| # 10-target budget (the "nothing new" branch continues before | |
| # the TARGETS append), so that cap is NOT what this relieves. | |
| # Safe because comments, reviews, labels, and pushes all bump | |
| # updatedAt or route in real time; the two scan-only signals | |
| # that do NOT bump it — a base conflict appearing when main | |
| # moves, and still-red checks awaiting the redcheck marker — | |
| # wait out the backoff on a PR nobody touched in a day, then | |
| # self-correct (the eventual address run comments/pushes). The | |
| # slot is keyed by PR number mod 4 against a 600s time quantum | |
| # (same quantum as ROT_OFF), so each scan is an independent | |
| # ~25% draw per idle PR — about one scan in four. This is NOT a | |
| # bounded gap: the scheduled scan lands every ~40-70 min on | |
| # this repo (not the */10 the cron implies), so the wait is | |
| # geometric — measured median ~2h, p90 ~6h across 100 real | |
| # scans. The forced-dispatch path never builds the list files, | |
| # so a forced PR is always inspected (fail-open, like a PR | |
| # missing from the set). | |
| IDLE_PRS=' ' | |
| if [[ -f "${WORKDIR}/bot-prs.json" && -f "${WORKDIR}/takeover-prs.json" ]]; then | |
| IDLE_CUTOFF="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" | |
| IDLE_PRS=" $(jq -rs --arg cut "${IDLE_CUTOFF}" 'add | unique_by(.number) | |
| | map(select((.updatedAt // "") != "" and .updatedAt < $cut) | .number | tostring) | |
| | join(" ")' \ | |
| "${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json" 2> /dev/null || echo '') " | |
| fi | |
| IDLE_SLOT_NOW="$(( ($(date -u +%s) / 600) % 4 ))" | |
| TARGETS='[]' | |
| INSPECTED=0 | |
| for PR in ${CANDIDATES}; do | |
| # The busy skip is free (in-memory set) — it must not consume | |
| # inspection budget. | |
| if [[ "${BUSY_PRS}" == *" ${PR} "* ]]; then | |
| echo "⏳ #${PR}: review-address already in flight or queued — skipping" | |
| fleet_row "${PR}" 'busy' 'address run in flight' | |
| continue | |
| fi | |
| # The idle-backoff skip is free too (a bash substring test on | |
| # the precomputed set, same idiom as the busy skip) — it must | |
| # not consume inspection budget either. | |
| if [[ "${IDLE_PRS}" == *" ${PR} "* && "$(( PR % 4 ))" != "${IDLE_SLOT_NOW}" ]]; then | |
| echo "😴 #${PR}: idle >24h — deferring to its rotation slot (slot $(( PR % 4 )), current ${IDLE_SLOT_NOW}; inspected ~1 scan in 4)" | |
| fleet_row "${PR}" 'idle-backoff' 'idle >24h; inspected ~1 scan in 4 (median ~2h, p90 ~6h)' | |
| continue | |
| fi | |
| INSPECTED=$(( INSPECTED + 1 )) | |
| if [[ "${INSPECTED}" -gt "${MAX_CANDIDATE_INSPECTIONS}" ]]; then | |
| echo "🧮 candidate-inspection budget (${MAX_CANDIDATE_INSPECTIONS}) reached — deferring the rest to the next scan" | |
| fleet_row '-' 'deferred' "candidate-inspection budget (${MAX_CANDIDATE_INSPECTIONS}) reached — remaining candidates deferred to next scan" | |
| break | |
| fi | |
| # One PR fetch for the branch name, check rollup, creation time | |
| # (the watermark floor below), and labels (the effective round | |
| # cap) — avoids extra round-trips per candidate PR. | |
| PR_META="$(gh pr view "${PR}" --repo "${REPO}" \ | |
| --json headRefName,headRefOid,statusCheckRollup,createdAt,labels,isCrossRepository,headRepositoryOwner,headRepository 2> /dev/null || echo '{}')" | |
| HEAD_REPO_FULL="${REPO}" | |
| if [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" == "true" ]]; then | |
| HR_OWNER="$(jq -r '.headRepositoryOwner.login // ""' <<< "${PR_META}")" | |
| HR_NAME="$(jq -r '.headRepository.name // ""' <<< "${PR_META}")" | |
| # Component-wise: a deleted fork yields owner XOR name empty, | |
| # which a joined '/' test would wave through into a red fetch. | |
| if [[ -z "${HR_OWNER}" || -z "${HR_NAME}" ]]; then | |
| echo "⚠️ #${PR}: fork head repository unresolved (owner='${HR_OWNER}' name='${HR_NAME}') — skipping" | |
| fleet_row "${PR}" 'skipped' "fork head unresolved (owner='${HR_OWNER}' name='${HR_NAME}')" | |
| continue | |
| fi | |
| HEAD_REPO_FULL="${HR_OWNER}/${HR_NAME}" | |
| fi | |
| BRANCH="$(jq -r '.headRefName // ""' <<< "${PR_META}")" | |
| HAS_TAKEOVER="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_META}")" | |
| # The candidate snapshot filtered skip once, but this per-PR fetch | |
| # is FRESHER — a skip label applied mid-scan must still win (the | |
| # address job re-checks it live once more before any mutation). | |
| if [[ "$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_META}")" == "true" ]]; then | |
| echo "⏭️ #${PR}: ${SKIP_LABEL} label present (applied mid-scan) — skipping" | |
| fleet_row "${PR}" 'skipped' "${SKIP_LABEL} label present" | |
| continue | |
| fi | |
| EFF_MAX_ROUNDS="${MAX_ROUNDS}" | |
| [[ "${HAS_TAKEOVER}" == "true" ]] && EFF_MAX_ROUNDS="${TAKEOVER_MAX_ROUNDS}" | |
| if [[ -z "${BRANCH}" ]]; then | |
| # Metadata fetch failed (transient API error / rate limit). Skip rather | |
| # than fall through with an empty branch, which would make the address | |
| # job fail at `git checkout -B "" origin/` and post a misleading "could | |
| # not start evaluation" handoff. Retried on the next scan. (This also | |
| # means CREATED_WM below is only reached with a populated PR_META.) | |
| echo "⚠️ #${PR}: could not fetch PR metadata (API error); skipping until next scan" | |
| fleet_row "${PR}" 'unknown' 'PR metadata unreadable (API error)' | |
| continue | |
| fi | |
| # Extract issue number: autofix/issue-<N> → N; otherwise use PR number. | |
| if [[ "${BRANCH}" == "${BRANCH_PREFIX}"* ]]; then | |
| ISSUE="${BRANCH#"${BRANCH_PREFIX}"}" | |
| else | |
| ISSUE="${PR}" | |
| fi | |
| CHECKS_JSON="$(jq -c '.statusCheckRollup // []' <<< "${PR_META}")" | |
| PR_HEAD_OID="$(jq -r '.headRefOid // ""' <<< "${PR_META}")" | |
| # Auto-rerun a check that died on INFRASTRUCTURE, not the code (see | |
| # INFRA_FAILURE_SIGNATURES). Only reached when the PR has a FAILED | |
| # check; then, for each, we read its annotations and — if they carry | |
| # a machine-death signature — rerun that run's failed jobs ONCE. The | |
| # once is enforced by run_attempt: a run already retried to attempt 2 | |
| # and still infra-failing is persistent, so we stop and leave it. No | |
| # marker needed; the attempt counter is the guard, and after a rerun | |
| # the attempt increments so the next scan skips it. Any API failure | |
| # here is fail-safe: it just means no rerun. | |
| if [[ -n "${PR_HEAD_OID}" ]] && jq -e 'any(.[]; ((.conclusion // .state // "") | IN("FAILURE","FAILED","ERROR","TIMED_OUT","ACTION_REQUIRED")) and (((.workflowName // "") != "Qwen Autofix") or ((.name // "") | startswith("review-address"))))' <<< "${CHECKS_JSON}" > /dev/null 2>&1; then | |
| RERAN_INFRA=false | |
| # Failed check-runs on this head, with their run id and annotation | |
| # count — fetched once. External statuses (no check-run) are absent | |
| # here, which is fine: only Actions runs can be reran. | |
| FAILED_CRS="$(gh api --paginate "repos/${REPO}/commits/${PR_HEAD_OID}/check-runs" \ | |
| --jq '.check_runs[] | select(.conclusion == "failure") | |
| | select(((.output.annotations_count // 0) > 0)) | |
| | [(.id | tostring), (.details_url // ""), (.name // "")] | @tsv' 2> /dev/null | sort -u || true)" | |
| while IFS=$'\t' read -r CR_ID DETAILS_URL CR_NAME; do | |
| [[ -n "${CR_ID}" && "${DETAILS_URL}" == *"/actions/runs/"* ]] || continue | |
| RUN_ID="${DETAILS_URL#*/actions/runs/}" | |
| RUN_ID="${RUN_ID%%/*}" | |
| [[ "${RUN_ID}" =~ ^[0-9]+$ ]] || continue | |
| ANNS="$(gh api --paginate "repos/${REPO}/check-runs/${CR_ID}/annotations" \ | |
| --jq '[.[].message] | join("\n")' 2> /dev/null || true)" | |
| grep -qiE "${INFRA_FAILURE_SIGNATURES}" <<< "${ANNS}" || continue | |
| RUN_META="$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq '[(.run_attempt // 0), (.name // "")] | @tsv' 2> /dev/null || printf '0\t')" | |
| ATTEMPT="${RUN_META%%$'\t'*}" | |
| WF_NAME="${RUN_META#*$'\t'}" | |
| # Mirror the gate's self-trigger guard: skip Qwen Autofix runs | |
| # unless the check is a review-address job. | |
| if [[ "${WF_NAME}" == "Qwen Autofix" && "${CR_NAME}" != review-address* ]]; then | |
| continue | |
| fi | |
| if [[ "${ATTEMPT}" == '1' ]]; then | |
| if gh api -X POST "repos/${REPO}/actions/runs/${RUN_ID}/rerun-failed-jobs" > /dev/null 2>&1; then | |
| echo "♻️ #${PR}: a check died on infrastructure (run ${RUN_ID}, attempt 1) — reran its failed jobs; not the PR's code" | |
| fleet_row "${PR}" 'infra-reran' "infra failure (run ${RUN_ID}) reran once" | |
| RERAN_INFRA=true | |
| break | |
| fi | |
| else | |
| echo "⚠️ #${PR}: infra failure persisted after a rerun (run ${RUN_ID}, attempt ${ATTEMPT}) — leaving for a human" | |
| fi | |
| done <<< "${FAILED_CRS}" | |
| [[ "${RERAN_INFRA}" == 'true' ]] && continue | |
| fi | |
| # startedAt is the only staleness clock: a check blocks only if it | |
| # started within the bound; one with no startedAt (queued, not yet | |
| # running) is not blocking (the next scan re-checks once it starts). | |
| HAS_PENDING_CHECKS="$(jq -r --arg cut "${PENDING_CUTOFF}" \ | |
| --argjson nonblocking "${NON_BLOCKING_CHECKS}" ' | |
| [ .[] | |
| | select((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) | |
| | select((.name // "") as $n | ($nonblocking | index($n)) == null) | |
| | select((.startedAt // $cut) > $cut) ] | |
| | length > 0 | |
| ' <<< "${CHECKS_JSON}")" | |
| if [[ "${HAS_PENDING_CHECKS}" == "true" ]]; then | |
| echo "⏳ #${PR}: active checks in flight; skipping until they finish (only checks stuck >${PENDING_STALE_MIN}m past their start are treated as dead and ignored)" | |
| fleet_row "${PR}" 'waiting' 'active checks in flight' | |
| continue | |
| fi | |
| # Pre-first-eval floor: the PR's IMMUTABLE creation time. Feedback | |
| # cannot predate the PR, and unlike the head commit date this never | |
| # advances when the branch is synced with main ("Update branch"/base | |
| # merge), so an early base-sync merge cannot bury a comment made before | |
| # the first eval. If the metadata query failed (empty), fall back to an | |
| # EMPTY floor — over-inclusive (evaluates all feedback once, then the | |
| # first eval writes a marker) but never buries. NEVER fall back to the | |
| # mutable head commit date: a base-sync HEAD would recreate the burial. | |
| CREATED_WM="$(jq -r '.createdAt // ""' <<< "${PR_META}")" | |
| # PAGINATION NOTE: gh >= v2.31.0 merges all pages of a REST array | |
| # endpoint into ONE flat JSON array (cli/cli#7190), so the WORKDIR | |
| # files below are single arrays on every hosted runner. The jq | |
| # normalizer pipes are retained as cheap defense-in-depth: | |
| # idempotent over a flat array, and they would also normalize the | |
| # per-page "[…][…]" shape that older gh versions emitted. | |
| gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/ic.json" | |
| # First-pickup engage ack: fork label events carry no secrets and | |
| # manual labels may race the ack job, so a takeover PR with NO | |
| # engage ack yet gets one here (identity-verified) — it is also | |
| # the round-window anchor. ic.json is re-fetched so THIS scan | |
| # already counts under the fresh key. ORDERING IS LOAD-BEARING: | |
| # ic.json for THIS candidate is fetched just above — reading a | |
| # previous candidate's file would mis-dedup (spurious re-ack → | |
| # window reset every scan), and a missing file would kill the | |
| # whole scan step under -eo pipefail. Dedup is author-filtered | |
| # (a forged human marker must not suppress the real ack), and a | |
| # label application NEWER than the latest bot ack means a fresh | |
| # engagement — post a fresh ack so the round window and cap | |
| # reset as documented (re-arm), which no ack job can do for | |
| # forks. | |
| NEED_ENGAGE_ACK='false' | |
| if [[ "${HAS_TAKEOVER}" == "true" ]]; then | |
| LAST_ENGAGE_ACK_TS="$(jq -rs --arg ab "${AUTOFIX_BOT}" ' | |
| add | [.[] | select((.user.login // "") == $ab) | |
| | select(.body // "" | contains("<!-- takeover-ack engaged -->")) | |
| | .created_at] | sort | last // ""' "${WORKDIR}/ic.json")" | |
| gh api "repos/${REPO}/issues/${PR}/events" --paginate 2> /dev/null \ | |
| | jq -s 'add // []' > "${WORKDIR}/pr-events.json" \ | |
| || echo '[]' > "${WORKDIR}/pr-events.json" | |
| LAST_LABELED_TS="$(jq -rs --arg lb "${TAKEOVER_LABEL}" ' | |
| add | [.[] | select(.event == "labeled") | |
| | select((.label.name // "") == $lb) | |
| | .created_at] | sort | last // ""' "${WORKDIR}/pr-events.json")" | |
| if [[ -z "${LAST_ENGAGE_ACK_TS}" ]]; then | |
| NEED_ENGAGE_ACK='true' | |
| # Grace windows keyed by WHO owns the missing ack, read from | |
| # the label event's actor (pr-events.json is already here). | |
| # A bot-applied label came from takeover-command, which posts | |
| # the ack itself within seconds — fork or in-repo alike — so | |
| # a SHORT grace covers the write's own latency and an | |
| # ic.json snapshot taken between the label write and the ack | |
| # landing; past it, the command's post failed and the next | |
| # scheduled scan heals it (≤10 min), instead of waiting on | |
| # a label event that may never arrive. A human-applied | |
| # in-repo label is owned by the | |
| # DEDICATED ack job, which needs job-spin-up time — the | |
| # longer grace stands. A human-labeled fork has no other | |
| # owner, so no grace: the scan posts right here. | |
| LAST_LABELED_BY="$(jq -rs --arg lb "${TAKEOVER_LABEL}" ' | |
| add | [.[] | select(.event == "labeled") | |
| | select((.label.name // "") == $lb)] | |
| | sort_by(.created_at) | last | .actor.login // ""' "${WORKDIR}/pr-events.json")" | |
| if [[ "${LAST_LABELED_BY}" == "${AUTOFIX_BOT}" ]]; then | |
| if [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "$(date -u -d '45 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" ]]; then | |
| echo "🧭 engage ack deferred for #${PR}: command-applied label <45s ago — the command's own ack is in flight" | |
| NEED_ENGAGE_ACK='false' | |
| fi | |
| elif [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" != "true" ]] \ | |
| && [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "$(date -u -d '3 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" ]]; then | |
| echo "🧭 engage ack deferred for #${PR}: in-repo label applied <3m ago — the ack job owns it" | |
| NEED_ENGAGE_ACK='false' | |
| fi | |
| else | |
| if [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "${LAST_ENGAGE_ACK_TS}" ]]; then | |
| NEED_ENGAGE_ACK='true' | |
| fi | |
| fi | |
| fi | |
| if [[ "${NEED_ENGAGE_ACK}" == "true" && "${DRY_RUN}" == "true" ]]; then | |
| echo "🧪 DRY-RUN: would post engage ack on #${PR} (window key untouched)" | |
| NEED_ENGAGE_ACK='false' | |
| fi | |
| if [[ "${NEED_ENGAGE_ACK}" == "true" ]]; then | |
| if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then | |
| SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" | |
| fi | |
| if [[ "${SCAN_BOT_ACTOR}" == "${AUTOFIX_BOT}" ]]; then | |
| if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"; then | |
| # Atomic re-fetch: ic.json already holds a successful full | |
| # fetch from above; a truncated stream must not leave it 0 | |
| # bytes (jq -s fails only AFTER the redirect truncates) — | |
| # MARKERS on an empty file exits 0 with '' and the round | |
| # cap silently resets. Empty history must still never | |
| # masquerade as "no markers", so swap in the fresh copy | |
| # only when the whole pipeline succeeded. | |
| if gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/ic.next.json"; then | |
| mv "${WORKDIR}/ic.next.json" "${WORKDIR}/ic.json" | |
| else | |
| echo "::warning::ic re-fetch after engage ack failed for #${PR}; window anchors on a later scan" | |
| fi | |
| else | |
| echo "::warning::first-pickup engage ack failed for #${PR}; window anchors on a later scan" | |
| fi | |
| else | |
| echo "::warning::engage ack skipped: PAT authenticates as '${SCAN_BOT_ACTOR}'" | |
| fi | |
| fi | |
| # Eval markers the bot left after a previous evaluation carry the | |
| # newest feedback timestamp it already considered, plus the round. | |
| # Only our own comments are trusted, so a spoofed marker is ignored. | |
| # NOTE: this regex is POSITIONAL — group .[0]=ts, .[2]=round — and must | |
| # match the marker string emitted at every write site verbatim (search | |
| # `autofix-eval ts=`: the push/report success, noop, and handoff steps). | |
| # Inserting or reordering a field here or at any write site silently | |
| # corrupts round tracking; keep the `ts= acted= round=` order in lockstep. | |
| MARKERS="$(jq -c --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") | |
| | [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[] | |
| | {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")" | |
| # The watermark is GLOBAL across all markers — feedback already | |
| # evaluated stays evaluated, no matter how counting windows move. | |
| # The terminal-handoff SENTINEL ts is excluded: it is a flag, not | |
| # an evaluation time, and letting it into the watermark would | |
| # filter all future feedback forever — making a re-arm after a | |
| # terminal handoff dead on arrival (terminal skipping itself is | |
| # round-based and window-scoped, so re-arm properly clears it). | |
| REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-rearm -->")) | |
| | .created_at ] | max // ""' "${WORKDIR}/ic.json")" | |
| # The head this PR's red checks were last reported against. Carried | |
| # as its OWN marker inside the eval comment rather than a new field | |
| # on autofix-eval, so none of the ts/acted/round parsers change — | |
| # and the comment still matches the eval filter, so the agent never | |
| # sees it as feedback. | |
| RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") | |
| | [ scan("<!-- autofix-redcheck head=([0-9a-f]+) -->") ] | .[] | |
| | {sha: .[0], at: ($c.created_at // "")} ] | |
| | sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")" | |
| # A maintainer '@qwen-code /retry' posts that marker to say | |
| # "evaluate this feedback again": markers written BEFORE it stop | |
| # holding the watermark down. This is the sanctioned exception to | |
| # the global rule above, and it replaces the old recovery - | |
| # deleting the bot's marker comment by hand, which erased the | |
| # audit trail and needed raw API access to do at all. | |
| EVAL_WM="$(jq -r --arg rearm "${REARM_AT}" ' | |
| map(select($rearm == "" or (.at > $rearm))) | |
| | map(.ts) | map(select(. != "9999-12-31T23:59:59Z")) | max // ""' <<< "${MARKERS}")" | |
| # ROUND counting is windowed by KEY EQUALITY, not timestamps: the | |
| # current window key is the created_at of the latest | |
| # '<!-- takeover-ack engaged -->' comment ('none' before any | |
| # takeover), every marker records the key of the window it was | |
| # produced in (win=…, legacy markers count as 'none'), and only | |
| # markers of the CURRENT window count toward the cap. Timestamp | |
| # windowing would race an in-flight address job selected before a | |
| # re-arm: its marker lands AFTER the ack and would instantly | |
| # re-cap the fresh window — key equality cannot. Within a window | |
| # the highest round wins (a terminal handoff marker must make the | |
| # scan skip regardless of order). | |
| REARM_KEY="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select(((.body // "") | contains("<!-- takeover-ack engaged -->")) | |
| or ((.body // "") | contains("<!-- autofix-rearm -->"))) | |
| | .created_at ] | max // "none"' "${WORKDIR}/ic.json")" | |
| ROUND="$(jq -r --arg key "${REARM_KEY}" 'map(select(.win == $key)) | map(.round) | max // 0' <<< "${MARKERS}")" | |
| # Effective watermark = what the agent has actually evaluated (its last | |
| # eval marker's newest-feedback timestamp), NOT the last push. A bot | |
| # fix always writes a marker, so a real fix advances this; a base-sync | |
| # "Merge branch 'main'" push (or any commit that did not evaluate | |
| # feedback) does NOT, so it can never bury unaddressed maintainer | |
| # comments under the watermark. Before the first evaluation there is no | |
| # marker, so fall back to the PR creation floor. | |
| if [[ -n "${EVAL_WM}" ]]; then | |
| EFF_WM="${EVAL_WM}" | |
| else | |
| EFF_WM="${CREATED_WM}" | |
| fi | |
| if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then | |
| echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human" | |
| fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry" | |
| # A FORCED dispatch refused here answers OUT LOUD. Observed on | |
| # #7836: the fleet shepherd detected a merge conflict, posted | |
| # "dispatched the autofix loop to resolve it", and the dispatch | |
| # died right here with only the log line above — the PR page | |
| # showed a promise, the run showed green, and the conflict sat | |
| # unhandled for hours. The shepherd also dedups per head SHA, | |
| # and a capped PR gets no pushes, so its head never changes: | |
| # silence here freezes conflict handling until a human notices | |
| # by accident. Gate on workflow_dispatch — that is the explicit | |
| # dispatch lever (the shepherd's `gh workflow run` or a human). | |
| # FORCED_PR is ALSO set for every trusted pull_request_review | |
| # (route emits pr_number for those), which is not an explicit | |
| # dispatch: answering each one here spammed 7 refusals on | |
| # #7836, so review submissions stay covered by the | |
| # once-per-window pause notice below. No dedup on the dispatch | |
| # itself: the shepherd sends at most one per head, and a human | |
| # asking twice deserves two answers. | |
| if [[ -n "${FORCED_PR}" && "${FORCED_PR}" == "${PR}" && "${EVENT_NAME}" == 'workflow_dispatch' ]]; then | |
| if [[ "${DRY_RUN}" == "true" ]]; then | |
| echo "🧪 DRY-RUN: would post cap-refused notice on #${PR}" | |
| else | |
| if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then | |
| SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" | |
| fi | |
| if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::warning::cap-refused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" | |
| elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n</details>\n\n<!-- takeover-cap-refused -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then | |
| echo "::warning::cap-refused notice failed for #${PR}" | |
| fi | |
| fi | |
| fi | |
| # A MANAGED PR pausing at its cap deserves a visible reminder — | |
| # maintainers otherwise learn about it only from workflow logs. | |
| # ALL managed PRs, not just takeover: the takeover-only gate | |
| # left standard bot PRs capping in silence (#7836 hit 10/10 | |
| # with zero PR-visible notice), which is the root of the | |
| # frozen-conflict chain above. Once per counting window: | |
| # re-arming opens a fresh window and, if the cap is hit again, | |
| # a fresh reminder. A failed post retries naturally on the | |
| # next scan (marker still absent). | |
| # Dedup boundary = the current window key; with no engage ack | |
| # or re-arm yet (key 'none') fall back to LIFETIME dedup — | |
| # created_at is never > 'none' lexically, which would flip | |
| # this into posting every scan. | |
| NOTICE_RT="${REARM_KEY}" | |
| [[ "${NOTICE_RT}" == "none" ]] && NOTICE_RT='' | |
| CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${NOTICE_RT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- takeover-cap-reached -->")) | |
| | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")" | |
| if [[ "${DRY_RUN}" == "true" ]]; then | |
| echo "🧪 DRY-RUN: would post cap-paused notice on #${PR}" | |
| elif [[ "${CAP_NOTICED}" == "0" ]]; then | |
| # Consent may have moved since PR_META: skip wins everywhere, | |
| # and a takeover notice additionally requires the label to | |
| # still be present — a label removed (or skip added) moments | |
| # ago must not receive a stale 'paused' notice. | |
| LIVE_LABELS="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null | jq -r '[.labels[]?.name] | join(" ")' || echo '')" | |
| if [[ " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* ]] \ | |
| || [[ "${HAS_TAKEOVER}" == "true" && " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* ]]; then | |
| echo "🧭 cap notice skipped: consent changed since the snapshot (labels: ${LIVE_LABELS:-unreadable})" | |
| continue | |
| fi | |
| # Convention: verify the PAT identity before ANY write. A | |
| # rotated PAT would post under a foreign login the dedup | |
| # (which counts AUTOFIX_BOT comments only) can never see — | |
| # reposting the notice every scan. Memoized per scan run. | |
| if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then | |
| SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" | |
| fi | |
| if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::warning::cap-paused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" | |
| else | |
| if [[ "${HAS_TAKEOVER}" == "true" ]]; then | |
| CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")" | |
| else | |
| CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")" | |
| fi | |
| if ! gh pr comment "${PR}" --repo "${REPO}" --body "${CAP_BODY}"; then | |
| echo "::warning::cap-paused notice failed for #${PR}; will retry next scan" | |
| fi | |
| fi | |
| fi | |
| continue | |
| fi | |
| # Auto-update a PR that is red ONLY because of a stale base (see the | |
| # MAIN_GREEN_CHECKS rationale above). The gate: the failing check also | |
| # passed for the PR that produced current main (a necessary-but-NOT- | |
| # sufficient signal — NOT proof main is healthy), and the PR is behind | |
| # or diverged, so it actually carries a stale base. Runs after the | |
| # round cap and pending-checks gates but before the feedback logic, | |
| # because a stuck-on-stale-base PR often has no NEW feedback at all (it | |
| # just sits red), which is exactly #7490's case. | |
| if [[ -n "${MAIN_HEAD}" && -n "${PR_HEAD_OID}" ]]; then | |
| # STALE_BASE_REDS is pure jq over data already in memory | |
| # (CHECKS_JSON, MAIN_GREEN_CHECKS) — free, and far more selective | |
| # than the compare round-trip. Compute it FIRST and skip the network | |
| # call entirely when there is no stale-base red to act on (the common | |
| # case: a green PR, or one whose red check is also red on main). | |
| # CANCELLED is deliberately omitted from the PR-side selector: a | |
| # cancelled check is not evidence of a stale base. External commit | |
| # statuses are also excluded: a StatusContext exposes .context, not | |
| # .name/.workflowName, so it yields "" and select(. != "") drops it | |
| # (conservative — only Actions check-runs are matched). | |
| STALE_BASE_REDS="$(jq -c -n \ | |
| --argjson checks "${CHECKS_JSON}" --argjson green "${MAIN_GREEN_CHECKS}" ' | |
| [ $checks[] | |
| | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) | |
| | select((.workflowName // "") != "Qwen Autofix") | |
| | (.name // .workflowName // "") | |
| | select(. != "" and (. as $n | $green | index($n))) ]')" || STALE_BASE_REDS='[]' | |
| if [[ "${STALE_BASE_REDS}" != '[]' ]]; then | |
| # --jq '.status': the compare document is ~60KB; only the | |
| # behind/diverged/ahead status is needed. | |
| CMP_STATUS="$(gh api "repos/${REPO}/compare/${MAIN_HEAD}...${PR_HEAD_OID}" --jq '.status // ""' 2> /dev/null || echo '')" | |
| if [[ "${CMP_STATUS}" == 'behind' || "${CMP_STATUS}" == 'diverged' ]]; then | |
| # Repetition guard: a marker comment bounds re-updates to | |
| # once per 2 hours (see BASE_UPDATE_CUTOFF, hoisted above the | |
| # loop). Without this, a still-red PR would be re-updated on | |
| # every scan after main advances. | |
| BASE_UPDATE_RECENT="$(jq -r --arg ab "${AUTOFIX_BOT}" \ | |
| --arg cutoff "${BASE_UPDATE_CUTOFF}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-base-updated -->")) | |
| | select((.created_at // "") > $cutoff) ] | length > 0' "${WORKDIR}/ic.json")" | |
| if [[ "${BASE_UPDATE_RECENT}" != "true" ]]; then | |
| RED_NAMES="$(jq -r 'join(", ")' <<< "${STALE_BASE_REDS}")" | |
| if [[ "${DRY_RUN}" == "true" ]]; then | |
| echo "🧪 DRY-RUN: would update stale base on #${PR} (red [${RED_NAMES}] green on main ${MAIN_HEAD:0:9})" | |
| fleet_row "${PR}" 'dry-run-base' "would merge main (stale-base red [${RED_NAMES}])" | |
| continue | |
| fi | |
| # Convention: verify the PAT identity before ANY write (same | |
| # as the engage ack and cap notice above). update-branch AND | |
| # its marker are writes; a rotated PAT would do both under a | |
| # foreign login the dedup (which counts AUTOFIX_BOT comments | |
| # only) can never see — re-updating every scan. Memoized. | |
| if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then | |
| SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" | |
| fi | |
| if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::warning::#${PR}: stale-base update skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" | |
| fleet_row "${PR}" 'base-update-skipped' "stale-base red [${RED_NAMES}] but PAT identity '${SCAN_BOT_ACTOR}' != ${AUTOFIX_BOT}" | |
| # expected_head_sha makes this a compare-and-swap: if the | |
| # author pushed between our compare read and this call, | |
| # GitHub rejects it rather than merging main into an | |
| # unverified head. | |
| elif UPDATE_ERR="$(gh api -X PUT "repos/${REPO}/pulls/${PR}/update-branch" -f expected_head_sha="${PR_HEAD_OID}" 2>&1 >/dev/null)"; then | |
| echo "🔀 #${PR}: red check(s) [${RED_NAMES}] pass on current main ${MAIN_HEAD:0:9} — merged main in via update-branch; CI will re-run" | |
| fleet_row "${PR}" 'base-updated' "stale-base red [${RED_NAMES}] — merged current main, CI re-running" | |
| # The marker is the ONLY repetition guard for this mutating | |
| # action; a failed post must be loud, not swallowed, so the | |
| # dedup gap is visible (else the next scan re-updates). | |
| gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🔀 Base updated: red check(s) [%s] pass on current main — merged current main via update-branch; CI will re-run.\n\n<details>\n<summary>中文说明</summary>\n\n🔀 已更新 base:红色检查 [%s] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。\n\n</details>\n\n<!-- autofix-base-updated -->' "${RED_NAMES}" "${RED_NAMES}")" > /dev/null 2>&1 \ | |
| || echo "::warning::#${PR}: base-updated marker post failed — the 2h repetition guard is NOT armed for this update" | |
| continue | |
| else | |
| echo "⚠️ #${PR}: wanted to update the stale base (red [${RED_NAMES}] green on main) but update-branch failed: ${UPDATE_ERR:-unknown error}" | |
| fleet_row "${PR}" 'base-update-failed' "stale-base red [${RED_NAMES}] but update-branch failed" | |
| # A failed update (merge conflict, CAS rejection, or | |
| # missing allow-edits) is a human problem, but the bot's | |
| # review comments need not be deferred forever — fall | |
| # through to feedback processing. | |
| fi | |
| fi | |
| fi | |
| fi | |
| fi | |
| N_FAILED_CHECKS="$(jq --arg wm "${EFF_WM}" ' | |
| [ .[] | |
| | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) | |
| | select((.completedAt // .updatedAt // "") > $wm) ] | |
| | length | |
| ' <<< "${CHECKS_JSON}")" | |
| # A red check is a persistent STATE, not the instant it turned red. | |
| # Counting only "failed since the watermark" made a still-failing PR | |
| # invisible the moment the watermark passed the failure: measured on | |
| # #6451 (3 reds, all completed 09:30-09:51, watermark 10:55), | |
| # #7357 (red 07:59, watermark 09:18) and #7390 (red and watermark | |
| # both 11:27:37, so a strict `>` hid it the instant it appeared) — | |
| # all three sat red for hours while every scan logged "nothing new". | |
| # | |
| # So: a currently-red check counts as feedback until the head it ran | |
| # against has been evaluated. The address job records the head it | |
| # reported on; a PR whose recorded head still matches is left alone, | |
| # which bounds this to ONE look per head instead of every scan. | |
| # Empty LIVE_HEAD → N_RED_NOW stays 0: fail-closed (no head → cannot judge → do not act), | |
| # unlike the recording side where an empty REPORT_HEAD keeps reds visible. | |
| LIVE_HEAD="$(jq -r '.headRefOid // ""' <<< "${PR_META}")" | |
| N_RED_NOW=0 | |
| if [[ -n "${LIVE_HEAD}" && "${RED_HEAD}" != "${LIVE_HEAD}" ]]; then | |
| N_RED_NOW="$(jq ' | |
| [ .[] | |
| | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) ] | |
| | length | |
| ' <<< "${CHECKS_JSON}")" | |
| fi | |
| gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/rv.json" | |
| gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/rc.json" | |
| N_REVIEWS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson trust "${TRUSTED_ASSOC}" ' | |
| [ .[] | |
| | select((.submitted_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) ] | length' \ | |
| "${WORKDIR}/rv.json")" | |
| # Per AGENTS.md's review policy, Suggestion-level findings are | |
| # actionable during the initial change-producing rounds; takeover | |
| # extends that window. The scan still selects later feedback so | |
| # prepare can record and watermark its deferral; only the address | |
| # job filters what may drive code changes. | |
| N_COMMENTS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson trust "${TRUSTED_ASSOC}" ' | |
| [ .[] | |
| | select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) ] | length' \ | |
| "${WORKDIR}/rc.json")" | |
| # Issue-level PR comments are also actionable feedback. Exclude the | |
| # bot's own eval markers, and known non-actionable bot comments | |
| # (triage stages, coverage reports, legacy suggestion summaries, | |
| # force-push reminders). | |
| BOT_COMMENT_FILTER='<!-- (autofix-eval|autofix-rearm|autofix-base-updated|autofix-milestone|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ' | |
| # Command-style comments (@qwen-code /takeover, /triage, /review …) | |
| # are INSTRUCTIONS to tooling, not review feedback on the diff: | |
| # counting them as actionable burns a full agent cycle to post a | |
| # no-action report about the command itself (and the takeover | |
| # command would self-trigger such a round on every engagement). | |
| COMMAND_FILTER='^\s*@qwen-code /' | |
| N_ISSUE_COMMENTS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson trust "${TRUSTED_ASSOC}" --arg bf "${BOT_COMMENT_FILTER}" --arg cf "${COMMAND_FILTER}" ' | |
| [ .[] | |
| | select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.body // "") | test($bf) | not) | |
| | select((.body // "") | test($cf) | not) ] | length' \ | |
| "${WORKDIR}/ic.json")" | |
| # mergeable: GitHub may report UNKNOWN until it recomputes; treat only | |
| # an explicit CONFLICTING as a conflict so we never block on UNKNOWN. | |
| MERGEABLE="$(gh pr view "${PR}" --repo "${REPO}" --json mergeable --jq '.mergeable' 2> /dev/null || echo 'UNKNOWN')" | |
| HAS_CONFLICT='false' | |
| if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then HAS_CONFLICT='true'; fi | |
| if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${N_RED_NOW}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then | |
| echo "✅ #${PR}: nothing new since ${EFF_WM} (conflict=${HAS_CONFLICT})" | |
| fleet_row "${PR}" 'idle' "nothing new since ${EFF_WM} (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})" | |
| continue | |
| fi | |
| echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) + ${N_FAILED_CHECKS} failed check(s) new, ${N_RED_NOW} still-red on ${LIVE_HEAD:0:9}, conflict=${HAS_CONFLICT}, round=${ROUND}" | |
| fleet_row "${PR}" 'SELECTED' "${N_REVIEWS} review + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} comment + ${N_FAILED_CHECKS} failed-check new, ${N_RED_NOW} still-red (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})" | |
| TARGETS="$(jq -c \ | |
| --arg pr "${PR}" --arg branch "${BRANCH}" --arg issue "${ISSUE}" \ | |
| --arg round "${ROUND}" --arg wm "${EFF_WM}" --arg mr "${EFF_MAX_ROUNDS}" \ | |
| --arg win "${REARM_KEY}" --arg hr "${HEAD_REPO_FULL}" \ | |
| '. + [{pr: $pr, branch: $branch, issue: $issue, round: $round, watermark: $wm, max_rounds: $mr, win: $win, head_repo: $hr}]' \ | |
| <<< "${TARGETS}")" | |
| # Fan out: emit EVERY eligible PR up to the per-scan budget. The | |
| # address matrix bounds simultaneity (max-parallel) and the per-PR | |
| # concurrency groups plus the busy-PR skip above prevent duplicate | |
| # same-PR runs, so one scan drains the whole backlog instead of | |
| # serving a single newest-first target per tick (which starved | |
| # older PRs for hours when cron ticks were sparse). The budget | |
| # break bounds this loop's RUNTIME and API usage too — each | |
| # candidate costs several serial API reads, so scanning past a | |
| # full budget would spend hundreds of calls for nothing. Never a | |
| # silent cap: the deferral is logged and the next scan picks up | |
| # the remainder (their signals persist). | |
| if [[ "$(jq 'length' <<< "${TARGETS}")" -ge "${MAX_TARGETS_PER_SCAN}" ]]; then | |
| echo "⚠️ target budget (${MAX_TARGETS_PER_SCAN}) reached; deferring the remaining candidates to the next scan" | |
| fleet_row '-' 'deferred' "target budget (${MAX_TARGETS_PER_SCAN}) reached — remaining candidates deferred to next scan" | |
| break | |
| fi | |
| done | |
| COUNT="$(jq 'length' <<< "${TARGETS}")" | |
| echo "📋 ${COUNT} PR(s) to process" | |
| { | |
| echo "### 🤖 AutoFix fleet (${COUNT} selected this scan)" | |
| echo | |
| echo '| PR | State | Detail |' | |
| echo '| --- | --- | --- |' | |
| if [[ -s "${FLEET_FILE}" ]]; then | |
| while IFS=$'\t' read -r f_pr f_state f_detail; do | |
| f_detail="${f_detail//|/\\|}" | |
| if [[ "${f_pr}" == '-' ]]; then | |
| printf '| — | %s | %s |\n' "${f_state}" "${f_detail}" | |
| else | |
| printf '| #%s | %s | %s |\n' "${f_pr}" "${f_state}" "${f_detail}" | |
| fi | |
| done < "${FLEET_FILE}" | |
| else | |
| echo '| - | no managed PRs inspected | - |' | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| echo "targets=${TARGETS}" >> "${GITHUB_OUTPUT}" | |
| echo "has_targets=$([[ "${COUNT}" -gt 0 ]] && echo true || echo false)" >> "${GITHUB_OUTPUT}" | |
| # =========================================================================== | |
| # REVIEW PHASE (build) — compile the trusted-base CLI bundle ONCE per scan | |
| # and fan it out to the address legs as an artifact. Each leg otherwise | |
| # repeated the same base build: measured 3.5-5 minutes of npm ci + build + | |
| # bundle per leg (6 legs ≈ 25 runner-minutes on one 2026-08 scan) before | |
| # the agent could start. Gated on has_targets so an idle tick builds | |
| # nothing — the issue phase runs only when there are no review targets, so | |
| # gating on do_issue too would rebuild on every quiet scheduled tick. | |
| # fetch-depth 1 suffices: nothing here needs history, and the legs (full | |
| # fetch) always contain this commit as an ancestor of their base fetch. | |
| # =========================================================================== | |
| build-cli: | |
| needs: ['route', 'review-scan'] | |
| if: |- | |
| ${{ needs.review-scan.outputs.has_targets == 'true' }} | |
| runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: 'read' | |
| outputs: | |
| base_sha: '${{ steps.meta.outputs.base_sha }}' | |
| steps: | |
| # Self-hosted runners reuse the workspace; a prior containerised job | |
| # can leave root-owned, read-only files anywhere in it. Restore | |
| # ownership and write permission unconditionally before checkout. | |
| - name: 'Restore workspace ownership' | |
| run: |- | |
| set -uo pipefail | |
| RUNNER_UID="$(id -u)" | |
| RUNNER_GID="$(id -g)" | |
| if [ "$RUNNER_UID" != "0" ]; then | |
| chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" | |
| fi | |
| chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" | |
| # Same sweep as the agent jobs: this checkout runs in a workspace | |
| # other pool jobs have written, and a surviving filter.*.smudge or | |
| # hook fires during it — in the job that builds the CLI bundle every | |
| # review-address leg then executes with a PAT in env. | |
| - name: 'Sanitize workspace git config' | |
| run: |- | |
| set -uo pipefail | |
| # `.git` is a directory in a normal checkout but a gitlink file in | |
| # a worktree; -e covers both, and a missing .git (first run) too. | |
| if [ ! -e .git ]; then | |
| echo "no prior workspace; nothing to sanitize" | |
| exit 0 | |
| fi | |
| # Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is | |
| # on the allowlist below (it carries no command itself), but it | |
| # activates `.git/config.worktree` — a second config file that | |
| # `git config --local` neither lists nor unsets, and that CAN carry | |
| # core.hooksPath. Verified in qwen-triage: a prior run can set | |
| # `--worktree core.hooksPath=/`, survive the sweep untouched, and | |
| # make the hooks deletion below walk /. Delete the file outright, | |
| # then drop the extension. | |
| rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true | |
| git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true | |
| # Rather than denylist each exec-vector family (which kept missing | |
| # new ones), KEEP a known-safe allowlist and --unset-all everything | |
| # else: this closes the whole class, including knobs not yet | |
| # enumerated. The kept set is only plumbing that carries no command | |
| # — repo format, remote, branch, fetch/gc/pack/index, safe.directory, | |
| # extensions, and submodule url/active/branch (NOT | |
| # submodule.*.update, which can be `!cmd`). actions/checkout | |
| # re-establishes remote/auth afterward. `|| true` on the grep: no | |
| # non-allowlisted keys (the steady state on an already-sanitized | |
| # runner) means grep exits 1, which would kill the step exactly | |
| # when there is nothing to clean. | |
| git config --local --name-only --list 2>/dev/null \ | |
| | { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \ | |
| | while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done | |
| # Belt and braces after the config scrub: only delete inside the | |
| # repository's own git dir. A hooks path resolving anywhere else is | |
| # unlinked, never swept — a recursive delete of a planted path is | |
| # far worse than a stale hook on a runner the pool re-cleans. | |
| # Resolve hooks with global/system config OUT of the way. Verified | |
| # in qwen-triage: with a global core.hooksPath set, `git rev-parse | |
| # --git-path hooks` returns that path, the guard below sees | |
| # "outside the git dir", and a planted `.git/hooks` symlink | |
| # survives untouched. Keep this resolution AFTER the sweep above. | |
| GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')" | |
| HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)" | |
| HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')" | |
| if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then | |
| # Match -type f OR -type l: a symlinked hook survives a bare | |
| # `-type f` sweep and still fires on the next checkout. | |
| find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true | |
| else | |
| # Resolves outside the git dir (or not at all). Warning and | |
| # walking away would leave a live hook directory that the next | |
| # git command executes, so unlink the ENTRY without descending | |
| # into it and put an empty hooks directory back. | |
| RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)" | |
| echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it." | |
| rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean." | |
| mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true | |
| git config --local --unset-all core.hooksPath 2>/dev/null || true | |
| fi | |
| - name: 'Checkout trusted base' | |
| uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 | |
| with: | |
| ref: '${{ github.event.repository.default_branch }}' | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| - name: 'Set up Node.js' | |
| uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 | |
| with: | |
| node-version: '22.x' | |
| cache: 'npm' | |
| cache-dependency-path: 'package-lock.json' | |
| # The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below | |
| # are duplicated in issue-autofix and review-address; the workflow | |
| # contract tests pin every copy in lockstep — edit them together. | |
| - name: 'Install dependencies and build' | |
| env: | |
| QWEN_SKIP_PREPARE: '1' | |
| run: |- | |
| for attempt in 1 2 3; do | |
| if npm ci --prefer-offline --no-audit --progress=false; then | |
| break | |
| fi | |
| if [[ "${attempt}" == "3" ]]; then | |
| exit 1 | |
| fi | |
| sleep $((attempt * 15)) | |
| done | |
| npm run build | |
| npm run bundle | |
| # Producer-side sanity: a bundle that cannot even answer --version | |
| # fails THIS job once, instead of every leg failing its restore or | |
| # first qwen invocation minutes later and N times over. | |
| - name: 'Prepare Qwen Code CLI' | |
| run: |- | |
| qwen_version="$(node -p "require('./package.json').version")" | |
| echo "Using checked-out Qwen Code bundle ${qwen_version}" | |
| qwen_bin="${RUNNER_TEMP}/qwen-bin" | |
| mkdir -p "${qwen_bin}" | |
| cat > "${qwen_bin}/qwen" <<'EOF' | |
| #!/usr/bin/env bash | |
| exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@" | |
| EOF | |
| chmod +x "${qwen_bin}/qwen" | |
| echo "${qwen_bin}" >> "${GITHUB_PATH}" | |
| PATH="${qwen_bin}:${PATH}" | |
| qwen --version | |
| # The repo-root dist/ plus packages/core/dist are shipped: | |
| # copy_bundle_assets.js already gathers every runtime asset (chunks, | |
| # vendor, web-shell, locales) under the root dist/, and the remaining | |
| # packages/*/dist would triple the artifact size without ever being | |
| # read by the legs — the verify gate's full `npm run build` wipes and | |
| # rebuilds each package's dist from branch sources (build_package.js | |
| # rms it first, so no staleness leaks through). packages/core/dist is | |
| # the exception: the settings-schema check runs BEFORE any build (on | |
| # every path, including no-action), and its generator — tsx run from | |
| # the repo root, whose tsconfig has NO `paths` — imports cli sources | |
| # that resolve '@qwen-code/qwen-code-core' through the workspace | |
| # symlink to core's dist entry point. Without it the generator crashes | |
| # with ERR_MODULE_NOT_FOUND and the gate misreports a deterministic | |
| # "settings schema is stale" rejection. The i18n check needs no dist: | |
| # it runs with cwd packages/cli, whose tsconfig `paths` map the | |
| # specifier to core's sources instead. | |
| - name: 'Upload CLI bundle' | |
| id: 'meta' | |
| run: |- | |
| echo "base_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" | |
| tar -czf "${RUNNER_TEMP}/qwen-cli-dist.tar.gz" dist packages/core/dist | |
| - name: 'Publish CLI bundle artifact' | |
| uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 | |
| with: | |
| name: 'qwen-autofix-cli-dist' | |
| path: '${{ runner.temp }}/qwen-cli-dist.tar.gz' | |
| retention-days: 1 | |
| if-no-files-found: 'error' | |
| # =========================================================================== | |
| # REVIEW PHASE (address) — one job per PR: triage, address (incl. conflict | |
| # resolution), verify, push, and report. | |
| # =========================================================================== | |
| review-address: | |
| needs: ['route', 'review-scan', 'build-cli'] | |
| if: |- | |
| ${{ needs.review-scan.outputs.has_targets == 'true' }} | |
| # Secret-bearing and executes PR code, but every target is live-gated to | |
| # write+ (internal) authors at scan AND address time. That is an | |
| # author-permission gate by design, not a head-repository gate: takeover | |
| # engages maintainer fork PRs, and the pattern matches qwen-code-pr-review, | |
| # whose ECS-routed review job also rides its upstream write+ check. The | |
| # job therefore runs host-side (no `container:`): the branch code it | |
| # executes is collaborator-authored — the same trust class ci.yml's | |
| # pick_runner routes onto this pool — and persistent-workspace residue is | |
| # scrubbed by the hygiene steps below. On pull_request / | |
| # pull_request_review events the ECS route additionally needs a same-repo | |
| # head or a write+ author (ci.yml's pick_runner form); issue_comment and | |
| # the other triggers skip that clause and rely on the live write+ gates. | |
| # Forks of this repo (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to | |
| # hosted. Docker availability on this pool is proven in-repo by | |
| # qwen-triage's container jobs, which run on the same runner labels. | |
| runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' | |
| timeout-minutes: 300 | |
| permissions: | |
| contents: 'read' | |
| strategy: | |
| fail-fast: false | |
| # Simultaneity bound for the whole fleet — the ONLY place different PRs | |
| # wait on each other (the per-scan target budget and the inspection | |
| # budget are both far from binding at the current pool size). | |
| # Measured at 3, on the scan that selected 7 PRs: the legs started | |
| # 3-at-a-time and each new one began 3-4s after a slot freed, so the | |
| # 7th PR waited 81 minutes for a slot it could have had immediately. | |
| # 5 halves that tail while staying a real bound — the point of the cap | |
| # is that a backlog cannot open an unbounded number of agent runs at | |
| # once, not the specific number. The 300-minute job cap raises the | |
| # worst-case hold to 5 runner-hours per slot (25 across the fleet) and | |
| # holds the per-PR head-write concurrency group for the same window. | |
| # The cap itself does not lengthen the queueing tail above; the raised | |
| # 120-minute budget does, for the PRs that exhaust it. | |
| max-parallel: 5 | |
| matrix: | |
| target: '${{ fromJSON(needs.review-scan.outputs.targets) }}' | |
| # Serialises every writer of THIS PR's head branch, across workflows. | |
| # GitHub concurrency groups are repository-scoped, so sharing one name with | |
| # qwen-code-pr-review.yml's resolve-pr job is what makes the two mutually | |
| # exclusive — a per-workflow name only guards against itself. | |
| # Without this, a `@qwen-code /resolve` and this job's own conflict path | |
| # both merge the base branch and both push. Observed on #7355: /resolve | |
| # pushed at 03:51, this job pushed at 04:05 and was rejected `fetch first`, | |
| # discarding a full agent run and leaving no marker to show for it. | |
| # Serialising is strictly better than racing: this job fetches the head by | |
| # NAME at job start, so the second run reads the winner's result instead of | |
| # a stale base — its work is usable and its push lands, rather than being | |
| # rejected and thrown away. It may still spend an agent run: the | |
| # address-time recheck re-verifies lifecycle and consent (state, labels, | |
| # author, base, head branch) but not whether the conflict is still there. | |
| # The prefix is a LITERAL on both sides: job-level `concurrency` cannot read | |
| # the `env` context, so the two files cannot share a constant. A test pins | |
| # them equal instead, because a rename in one file alone silently unlocks | |
| # the race again with nothing failing. | |
| concurrency: | |
| group: 'qwen-pr-head-write-${{ matrix.target.pr }}' | |
| cancel-in-progress: false | |
| env: | |
| REPO: '${{ github.repository }}' | |
| WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}' | |
| PR: '${{ matrix.target.pr }}' | |
| BRANCH: '${{ matrix.target.branch }}' | |
| ISSUE: '${{ matrix.target.issue }}' | |
| ROUND: '${{ matrix.target.round }}' | |
| WATERMARK: '${{ matrix.target.watermark }}' | |
| # The scan computed the effective cap from the takeover label | |
| # (TAKEOVER_MAX_ROUNDS for managed PRs, the strict default otherwise); | |
| # shadowing the workflow-level MAX_ROUNDS | |
| # makes every round message, marker, and cap gate in this job use it. | |
| MAX_ROUNDS: '${{ matrix.target.max_rounds }}' | |
| # The round-counting window key this target was selected under; every | |
| # marker this job writes records it, and prepare discards the job if a | |
| # re-arm superseded the key while it sat queued. | |
| WINDOW: '${{ matrix.target.win }}' | |
| # owner/name of the PR's HEAD repository — equals REPO for in-repo | |
| # branches; a fork for maintainer-fork takeover targets (pushable via | |
| # 'Allow edits from maintainers'). | |
| HEAD_REPO: '${{ matrix.target.head_repo }}' | |
| steps: | |
| # SECURITY: checkout trusted base code first. The PR branch is checked | |
| # out later in "Prepare branch and feedback" after the trusted CLI | |
| # bundle (built once from this base in build-cli) is in place. Without | |
| # this pin, pull_request_review events would check out the PR merge ref | |
| # by default, letting PR-controlled code influence the secret-bearing | |
| # address run. The ref is pinned to the SHA build-cli compiled — not | |
| # the live default branch — so a mid-run base push can never leave a | |
| # leg running a bundle built from DIFFERENT sources than its checkout. | |
| # The SHA is validated fail-loud FIRST: actions/checkout resolves an | |
| # empty ref to the event default — on pull_request_review triggers the | |
| # PR merge ref — so a broken build-cli output must fail this leg | |
| # instead of silently unpinning it. | |
| - name: 'Validate bundle SHA' | |
| env: | |
| BASE_SHA: '${{ needs.build-cli.outputs.base_sha }}' | |
| run: |- | |
| if [[ ! "${BASE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::build-cli published no usable base_sha ('${BASE_SHA:-<empty>}') — refusing to fall back to the event-default ref." | |
| exit 1 | |
| fi | |
| # Self-hosted runners reuse the workspace; a prior containerised job | |
| # can leave root-owned, read-only files anywhere in it. Restore | |
| # ownership and write permission unconditionally before checkout. | |
| - name: 'Restore workspace ownership' | |
| run: |- | |
| set -uo pipefail | |
| RUNNER_UID="$(id -u)" | |
| RUNNER_GID="$(id -g)" | |
| if [ "$RUNNER_UID" != "0" ]; then | |
| chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" | |
| fi | |
| chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" | |
| # Self-hosted runners keep the workspace between runs, and other pool | |
| # jobs execute human-authored code as the runner user, so a prior job | |
| # can plant git exec knobs (core.fsmonitor, filter.*.smudge, | |
| # diff.external, includeIf, hooks) in the local config that would fire | |
| # inside THIS job's PAT-bearing git steps. It keeps | |
| # a known-safe allowlist and unsets everything else, hardened against | |
| # the worktree-config and global-hooksPath bypasses verified in | |
| # qwen-triage on this pool. No-op on a fresh hosted runner. | |
| - name: 'Sanitize workspace git config' | |
| run: |- | |
| set -uo pipefail | |
| # `.git` is a directory in a normal checkout but a gitlink file in | |
| # a worktree; -e covers both, and a missing .git (first run) too. | |
| if [ ! -e .git ]; then | |
| echo "no prior workspace; nothing to sanitize" | |
| exit 0 | |
| fi | |
| # Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is | |
| # on the allowlist below (it carries no command itself), but it | |
| # activates `.git/config.worktree` — a second config file that | |
| # `git config --local` neither lists nor unsets, and that CAN carry | |
| # core.hooksPath. Verified in qwen-triage: a prior run can set | |
| # `--worktree core.hooksPath=/`, survive the sweep untouched, and | |
| # make the hooks deletion below walk /. Delete the file outright, | |
| # then drop the extension. | |
| rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true | |
| git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true | |
| # Rather than denylist each exec-vector family (which kept missing | |
| # new ones), KEEP a known-safe allowlist and --unset-all everything | |
| # else: this closes the whole class, including knobs not yet | |
| # enumerated. The kept set is only plumbing that carries no command | |
| # — repo format, remote, branch, fetch/gc/pack/index, safe.directory, | |
| # extensions, and submodule url/active/branch (NOT | |
| # submodule.*.update, which can be `!cmd`). actions/checkout | |
| # re-establishes remote/auth afterward. `|| true` on the grep: no | |
| # non-allowlisted keys (the steady state on an already-sanitized | |
| # runner) means grep exits 1, which would kill the step exactly | |
| # when there is nothing to clean. | |
| git config --local --name-only --list 2>/dev/null \ | |
| | { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \ | |
| | while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done | |
| # Belt and braces after the config scrub: only delete inside the | |
| # repository's own git dir. A hooks path resolving anywhere else is | |
| # unlinked, never swept — a recursive delete of a planted path is | |
| # far worse than a stale hook on a runner the pool re-cleans. | |
| # Resolve hooks with global/system config OUT of the way. Verified | |
| # in qwen-triage: with a global core.hooksPath set, `git rev-parse | |
| # --git-path hooks` returns that path, the guard below sees | |
| # "outside the git dir", and a planted `.git/hooks` symlink | |
| # survives untouched. Keep this resolution AFTER the sweep above. | |
| GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')" | |
| HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)" | |
| HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')" | |
| if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then | |
| # Match -type f OR -type l: a symlinked hook survives a bare | |
| # `-type f` sweep and still fires on the next checkout. | |
| find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true | |
| else | |
| # Resolves outside the git dir (or not at all). Warning and | |
| # walking away would leave a live hook directory that the next | |
| # git command executes, so unlink the ENTRY without descending | |
| # into it and put an empty hooks directory back. | |
| RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)" | |
| echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it." | |
| rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean." | |
| mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true | |
| git config --local --unset-all core.hooksPath 2>/dev/null || true | |
| fi | |
| - name: 'Checkout trusted base' | |
| uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 | |
| with: | |
| ref: '${{ needs.build-cli.outputs.base_sha }}' | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: 'Reset autofix workspace' | |
| run: |- | |
| rm -rf "${WORKDIR}" | |
| # 0700: the dir holds agent transcripts and decision files; the | |
| # sandbox container runs as this same user, so the tighter mode | |
| # costs the job nothing. umask at creation, not mkdir-then-chmod — | |
| # the chmod form leaves a world-readable window on this shared /tmp. | |
| (umask 077; mkdir -p "${WORKDIR}") | |
| # Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard | |
| # runner kill skips the always() teardown and run_id never repeats, | |
| # so nothing else ever reclaims them. | |
| find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true | |
| # The reused workspace's .git accumulates unreferenced objects | |
| # across fetch runs on this persistent pool; prune them. | |
| git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true | |
| # Stage the schema gate script from the TRUSTED BASE checkout before | |
| # "Prepare branch and feedback" switches the working tree to the PR | |
| # branch. The verify gate must run the trusted copy: a PR branch that | |
| # predates this script does not contain it (bash exits 127 and the gate | |
| # dies without an outcome), and an in-branch copy would let branch code | |
| # define its own gate. | |
| - name: 'Stage trusted schema gate and agent runner' | |
| run: |- | |
| cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh" | |
| cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh" | |
| cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" | |
| cp .github/scripts/run-autofix-review-verification.sh "${RUNNER_TEMP}/run-autofix-review-verification.sh" | |
| # The agent step runs AFTER prepare checks out the PR branch, so | |
| # invoking the runner from the working tree would execute | |
| # branch-controlled code on the host with the model key in env | |
| # (takeover targets human-authored branches). Stage the runner AND | |
| # its SKILL from the trusted base, MIRRORING the skill's on-disk | |
| # layout (autofix/{SKILL.md,scripts/run-agent.mjs}) — run-agent.mjs | |
| # resolves the skill as `<its dir>/../SKILL.md`, so a flat stage | |
| # (RUNNER_TEMP/run-agent.mjs) points ../SKILL.md at RUNNER_TEMP/.. | |
| # and the agent crashes with ENOENT before reading any feedback. | |
| # The mirror also means the model's instructions come from the | |
| # trusted base, never the PR branch. | |
| mkdir -p "${RUNNER_TEMP}/autofix-skill/scripts" | |
| cp .qwen/skills/autofix/SKILL.md "${RUNNER_TEMP}/autofix-skill/SKILL.md" | |
| cp .qwen/skills/autofix/scripts/run-agent.mjs "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" | |
| - name: 'Check runner environment' | |
| env: | |
| RUNNER_ENVIRONMENT: '${{ runner.environment }}' | |
| RUNNER_NAME: '${{ runner.name }}' | |
| run: |- | |
| case "${RUNNER_ENVIRONMENT}" in | |
| github-hosted|self-hosted) ;; | |
| *) | |
| echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}." | |
| exit 1 | |
| ;; | |
| esac | |
| # The label routing pins ecs-qwen, but a mis-labelled registration | |
| # must not silently claim a PAT-bearing 300-minute job — assert the | |
| # pool by name on the self-hosted branch too. | |
| if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then | |
| case "${RUNNER_NAME}" in | |
| ecs-qwen-*) ;; | |
| *) | |
| echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here." | |
| exit 1 | |
| ;; | |
| esac | |
| fi | |
| # Capability preflight for the persistent pool: this job's agent | |
| # runs inside the docker sandbox, and a missing daemon otherwise | |
| # surfaces only at 'Resolve sandbox image' — after npm ci/build | |
| # has already burned tens of minutes. Fail in seconds instead. | |
| # Hosted runners ship docker; the ECS pool's docker is proven by | |
| # qwen-triage's container jobs on the same labels. | |
| if ! docker info > /dev/null 2>&1; then | |
| echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start." | |
| exit 1 | |
| fi | |
| - name: 'Set up Node.js' | |
| uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 | |
| with: | |
| node-version: '22.x' | |
| cache: 'npm' | |
| cache-dependency-path: 'package-lock.json' | |
| - name: 'Install tmux' | |
| run: |- | |
| if command -v tmux > /dev/null 2>&1; then | |
| tmux -V | |
| elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then | |
| # sudo -n: a host without passwordless sudo must fail fast with a | |
| # clear message, not die on a password prompt (the pr-review pool | |
| # steps make the same assumption with `sudo -n ... || ::warning`). | |
| sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || { | |
| echo '::error::tmux is required on the autofix runner and passwordless install failed.' | |
| exit 1 | |
| } | |
| else | |
| echo '::error::tmux is required on the autofix runner.' | |
| exit 1 | |
| fi | |
| # npm ci only — the build itself ran ONCE in build-cli. The legs still | |
| # need node_modules: the agent builds/tests the PR branch, and the | |
| # verify gate rebuilds from branch sources (wiping the restored | |
| # packages/core/dist via build_package.js, so no base staleness leaks | |
| # into branch verification). The retry recipe (and the shim below) is | |
| # duplicated in issue-autofix and build-cli; the workflow contract | |
| # tests pin every copy in lockstep — edit them together. | |
| - name: 'Install dependencies' | |
| env: | |
| QWEN_SKIP_PREPARE: '1' | |
| run: |- | |
| for attempt in 1 2 3; do | |
| if npm ci --prefer-offline --no-audit --progress=false; then | |
| break | |
| fi | |
| if [[ "${attempt}" == "3" ]]; then | |
| exit 1 | |
| fi | |
| sleep $((attempt * 15)) | |
| done | |
| git config core.hooksPath .husky | |
| - name: 'Download CLI bundle' | |
| uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 | |
| with: | |
| name: 'qwen-autofix-cli-dist' | |
| path: '${{ runner.temp }}/cli-dist' | |
| - name: 'Restore CLI bundle' | |
| run: |- | |
| tar -xzf "${RUNNER_TEMP}/cli-dist/qwen-cli-dist.tar.gz" | |
| test -f dist/cli.js | |
| # The verify gate's pre-build settings-schema check resolves | |
| # '@qwen-code/qwen-code-core' through the workspace symlink to | |
| # this entry point (the root tsconfig has no `paths`; the i18n | |
| # check instead resolves core to sources via the packages/cli | |
| # `paths` map); a bundle without it makes the schema generator | |
| # crash with ERR_MODULE_NOT_FOUND. | |
| test -f packages/core/dist/index.js | |
| - name: 'Prepare Qwen Code CLI' | |
| run: |- | |
| qwen_version="$(node -p "require('./package.json').version")" | |
| echo "Using checked-out Qwen Code bundle ${qwen_version}" | |
| qwen_bin="${RUNNER_TEMP}/qwen-bin" | |
| mkdir -p "${qwen_bin}" | |
| cat > "${qwen_bin}/qwen" <<'EOF' | |
| #!/usr/bin/env bash | |
| exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@" | |
| EOF | |
| chmod +x "${qwen_bin}/qwen" | |
| echo "${qwen_bin}" >> "${GITHUB_PATH}" | |
| PATH="${qwen_bin}:${PATH}" | |
| qwen --version | |
| - name: 'Resolve sandbox image' | |
| run: |- | |
| node .github/scripts/resolve-sandbox-image.mjs \ | |
| "$(node -p "require('./package.json').config.sandboxImageUri")" | |
| - name: 'Prepare branch and feedback' | |
| id: 'prepare' | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| run: |- | |
| mkdir -p "${WORKDIR}" | |
| # ---- address-time eligibility recheck --------------------------- | |
| # Fan-out can hold this job queued for hours behind max-parallel, | |
| # and the matrix snapshot cannot see lifecycle changes: a PR | |
| # closed or merged while queued must not get a secret-bearing | |
| # agent run, a branch push, or a comment; an author/base/head | |
| # change must not be processed against stale assumptions. Re-fetch | |
| # and require the same shape the scan selected. A failed fetch is | |
| # UNKNOWN and discards too (fail closed — the next scan re-emits a | |
| # still-valid target). | |
| PR_LIVE="$(gh pr view "${PR}" --repo "${REPO}" \ | |
| --json state,author,isCrossRepository,baseRefName,headRefName,labels,maintainerCanModify,headRepositoryOwner,headRepository 2> /dev/null || echo '{}')" | |
| LIVE_STATE="$(jq -r '.state // ""' <<< "${PR_LIVE}")" | |
| LIVE_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_LIVE}")" | |
| LIVE_XREPO="$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_LIVE}")" | |
| LIVE_CAN_MODIFY="$(jq -r '.maintainerCanModify // false' <<< "${PR_LIVE}")" | |
| LIVE_BASE="$(jq -r '.baseRefName // ""' <<< "${PR_LIVE}")" | |
| LIVE_BRANCH="$(jq -r '.headRefName // ""' <<< "${PR_LIVE}")" | |
| # Engagement labels are re-read LIVE too: a takeover label grants a | |
| # human-authored PR the author exemption, and a skip label applied | |
| # while this job sat queued withdraws consent — both must hold at | |
| # the moment the secret-bearing run starts, not at scan time. | |
| LIVE_TAKEOVER="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_LIVE}")" | |
| LIVE_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_LIVE}")" | |
| INELIGIBLE='' | |
| if [[ "${PR_LIVE}" == '{}' ]]; then INELIGIBLE='metadata fetch failed (API error) — fail-closed' | |
| elif [[ "${LIVE_STATE}" != "OPEN" ]]; then INELIGIBLE="state='${LIVE_STATE:-unknown}'" | |
| elif [[ "${LIVE_SKIP}" == "true" ]]; then INELIGIBLE="${SKIP_LABEL} label present" | |
| elif [[ "${LIVE_AUTHOR}" != "${AUTOFIX_BOT}" && "${LIVE_TAKEOVER}" != "true" ]]; then INELIGIBLE="author='${LIVE_AUTHOR:-unknown}' without ${TAKEOVER_LABEL}" | |
| elif [[ "${LIVE_BASE}" != "main" ]]; then INELIGIBLE="base='${LIVE_BASE:-unknown}'" | |
| elif [[ "${LIVE_BRANCH}" != "${BRANCH}" ]]; then INELIGIBLE="head branch is now '${LIVE_BRANCH:-unknown}'" | |
| # Base/branch invariants sit ABOVE the fork chain: the last fork | |
| # elif ends the ladder for eligible forks, so anything below it | |
| # would be unreachable for exactly the PR class we fetch and push. | |
| elif [[ "${LIVE_XREPO}" != "false" && "${LIVE_TAKEOVER}" != "true" && "${LIVE_AUTHOR}" != "${AUTOFIX_BOT}" ]]; then INELIGIBLE='fork head without takeover (a non-bot fork needs the label)' | |
| elif [[ "${LIVE_XREPO}" != "false" && "${LIVE_CAN_MODIFY}" != "true" ]]; then INELIGIBLE='fork head without maintainer-edit access' | |
| elif [[ "${LIVE_XREPO}" != "false" ]]; then | |
| # Live author-privilege re-check for fork targets: an author who | |
| # lost write+ since the scan must not get a secret-bearing run. | |
| FORK_AUTHOR_PERM="$(gh api "repos/${REPO}/collaborators/${LIVE_AUTHOR}/permission" --jq '.permission // ""' 2> /dev/null || echo '')" | |
| case "${FORK_AUTHOR_PERM}" in | |
| admin|maintain|write) : ;; | |
| *) INELIGIBLE="fork author '${LIVE_AUTHOR}' permission='${FORK_AUTHOR_PERM:-none}' below write" ;; | |
| esac | |
| # The fetch source and token-push target must still be the LIVE | |
| # head repository — a fork renamed or transferred since the scan | |
| # would route both to a stale path. Fail-closed: moved or | |
| # unresolved discards; the next scan re-admits the fresh path. | |
| if [[ -z "${INELIGIBLE}" ]]; then | |
| LIVE_HR_OWNER="$(jq -r '.headRepositoryOwner.login // ""' <<< "${PR_LIVE}")" | |
| LIVE_HR_NAME="$(jq -r '.headRepository.name // ""' <<< "${PR_LIVE}")" | |
| if [[ -z "${LIVE_HR_OWNER}" || -z "${LIVE_HR_NAME}" || "${LIVE_HR_OWNER}/${LIVE_HR_NAME}" != "${HEAD_REPO:-${REPO}}" ]]; then | |
| INELIGIBLE="fork head repository moved or unresolved (live='${LIVE_HR_OWNER}/${LIVE_HR_NAME}' target='${HEAD_REPO:-${REPO}}')" | |
| fi | |
| fi | |
| fi | |
| if [[ -n "${INELIGIBLE}" ]]; then | |
| echo "🫥 target no longer eligible (${INELIGIBLE}) — discarding without action or marker" | |
| { | |
| echo "stale=true" | |
| echo "conflict=false" | |
| echo "newest=${WATERMARK}" | |
| echo "effective_round=${ROUND}" | |
| } >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| echo 'Restoring tracked build output before switching to the PR branch.' | |
| git status --short | |
| git restore --source=HEAD --staged --worktree . | |
| # This step's env carries the PAT, and checkout fires post-checkout | |
| # hooks — sever hooks here exactly like the push steps do; the | |
| # agent step (no PAT, sandboxed tools) re-points hooksPath at | |
| # .husky itself so ITS commits still get checked. | |
| git config core.hooksPath /dev/null | |
| if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then | |
| # Maintainer-fork target: the branch does not exist on origin — | |
| # fetch it (data only; hooks are severed) from the fork. | |
| if ! git fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"; then | |
| echo "🫥 fork fetch failed for ${HEAD_REPO} (${BRANCH}) — discarding without action or marker" | |
| { | |
| echo "stale=true" | |
| echo "conflict=false" | |
| echo "newest=${WATERMARK}" | |
| echo "effective_round=${ROUND}" | |
| } >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| git checkout -B "${BRANCH}" FETCH_HEAD | |
| # Create the remote-tracking ref so the verification gate's | |
| # `git diff --quiet "origin/${BRANCH}...${BRANCH}"` works for | |
| # fork PRs the same way it does for same-repo branches. | |
| git update-ref "refs/remotes/origin/${BRANCH}" "${BRANCH}" | |
| # Allow-edits pushes ride the classic-PAT grant — GITHUB_TOKEN | |
| # and fine-grained PATs are documented as NOT receiving it. | |
| # Prove push access NOW, before an agent round is spent, instead | |
| # of 403ing at the report step after the work is done. | |
| if ! git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \ | |
| push --no-verify --dry-run "https://github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}" > /dev/null 2>&1; then | |
| echo "🫥 fork push preflight failed for ${HEAD_REPO} (allow-edits grant or PAT type) — discarding without action or marker" | |
| { | |
| echo "stale=true" | |
| echo "conflict=false" | |
| echo "newest=${WATERMARK}" | |
| echo "effective_round=${ROUND}" | |
| } >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| else | |
| git checkout -B "${BRANCH}" "origin/${BRANCH}" | |
| fi | |
| # The exact SHA the agent will evaluate. Captured HERE, before any | |
| # agent mutation, so the marker records what was actually looked at — | |
| # not the report-time remote head, which can move during the run. | |
| CHECKED_OUT_HEAD="$(git rev-parse HEAD)" | |
| echo "checked_out_head=${CHECKED_OUT_HEAD}" >> "${GITHUB_OUTPUT}" | |
| # Does the branch conflict with base? merge-tree computes the merge | |
| # without touching the tree; exit 1 means conflicts. UNKNOWN/errors are | |
| # treated as no-conflict so we never block on a transient state. | |
| CONFLICT='false' | |
| if git merge-tree --write-tree origin/main HEAD > /dev/null 2>&1; then | |
| CONFLICT='false' | |
| elif [[ "$?" == "1" ]]; then | |
| CONFLICT='true' | |
| fi | |
| echo "conflict=${CONFLICT}" >> "${GITHUB_OUTPUT}" | |
| echo "🔀 Conflict with base: ${CONFLICT}" | |
| # Same flat-array normalization as the scan step (see its PAGINATION | |
| # NOTE). NEWEST / LIVE_NEW below bind these files POSITIONALLY | |
| # (.[0]..[3]), so each must remain a single well-formed array. | |
| gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/rv.json" | |
| gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/rc.json" | |
| gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ | |
| | jq -s 'add // []' > "${WORKDIR}/ic.json" | |
| gh pr view "${PR}" --repo "${REPO}" \ | |
| --json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \ | |
| 2> /dev/null || echo '[]' > "${WORKDIR}/checks.json" | |
| # Newest actionable feedback timestamp — stamped into the eval marker so | |
| # the next scan knows everything up to here has been considered. | |
| # Includes reviews, inline review comments, and issue-level PR comments | |
| # (excluding the bot's own eval markers). | |
| NEWEST="$(jq -rs \ | |
| --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson trust "${TRUSTED_ASSOC}" ' | |
| (.[0] | map(select((.submitted_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) | .submitted_at)) | |
| + (.[1] | map(select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | .created_at)) | |
| + (.[2] | map(select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | |
| | select((.body // "") | test("^\\s*@qwen-code /") | not) | .created_at)) | |
| + (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) | |
| | select((.completedAt // .updatedAt // "") > $wm) | |
| | (.completedAt // .updatedAt // ""))) | |
| | max // ""' "${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json")" | |
| [[ -z "${NEWEST}" ]] && NEWEST="${WATERMARK}" | |
| echo "newest=${NEWEST}" >> "${GITHUB_OUTPUT}" | |
| # Live-watermark revalidation: two near-simultaneous triggers for the | |
| # SAME PR can both pass their (per-target, route-level) gates and both | |
| # scan before either has emitted a matrix job, so both emit this PR | |
| # with the same stale watermark. The per-PR address concurrency group | |
| # QUEUES the duplicate rather than discarding it — but that queueing | |
| # is exactly what makes this check sound: address jobs for one PR run | |
| # strictly one at a time, so by the time the duplicate runs here, the | |
| # first job's eval marker is posted and visible. Three duplicate | |
| # signatures: (a) a sibling evaluated through a NEWER live ts than | |
| # our matrix watermark; (b) a conflict-only sibling resolved and | |
| # marked at the SAME ts — with no newer feedback its marker keeps | |
| # ts=watermark while its ROUND advances past ours (ours is the max | |
| # round observed at scan time); (c) a no-op sibling judged THIS exact | |
| # head (its redcheck marker matches CHECKED_OUT_HEAD) while keeping | |
| # BOTH ts and round unchanged — neither (a) nor (b) fires, but | |
| # re-running would post a duplicate report for the same head. Either | |
| # way, if there is no live conflict left and nothing newer than the | |
| # live watermark, this run is a stale duplicate and discards itself. | |
| STALE='false' | |
| LIVE_MARKS="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") | |
| | [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[] | |
| | {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")" | |
| LIVE_REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-rearm -->")) | |
| | .created_at ] | max // ""' "${WORKDIR}/ic.json")" | |
| # Mirrors the scan: a /retry marker releases the watermark it holds. | |
| LIVE_EVAL_WM="$(jq -r --arg rearm "${LIVE_REARM_AT}" ' | |
| map(select($rearm == "" or (.at > $rearm))) | |
| | map(.ts) | map(select(. != "9999-12-31T23:59:59Z")) | max // ""' <<< "${LIVE_MARKS}")" | |
| # Round counting mirrors the scan: keyed to the CURRENT window (the | |
| # latest engage ack's created_at, 'none' before any takeover), so | |
| # pre-re-arm markers can neither trip the cap nor inflate the live | |
| # round here. | |
| LIVE_REARM_KEY="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select(((.body // "") | contains("<!-- takeover-ack engaged -->")) | |
| or ((.body // "") | contains("<!-- autofix-rearm -->"))) | |
| | .created_at ] | max // "none"' "${WORKDIR}/ic.json")" | |
| LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" 'map(select(.win == $key)) | map(.round) | max // 0' <<< "${LIVE_MARKS}")" | |
| # The head a sibling last judged, mirrored from the scan's RED_HEAD | |
| # parse. A no-op sibling records this marker while leaving BOTH ts and | |
| # round UNCHANGED — so the watermark/round triggers below never fire, | |
| # yet a second same-watermark target for the same head would otherwise | |
| # run the agent again and post a duplicate report for that head. | |
| LIVE_RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") | |
| | [ scan("<!-- autofix-redcheck head=([0-9a-f]+) -->") ] | .[] | |
| | {sha: .[0], at: ($c.created_at // "")} ] | |
| | sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")" | |
| # A re-arm SUPERSEDES every job selected under the old window key: | |
| # the maintainer asked for a fresh start, so a queued old-window job | |
| # must discard instead of finishing and stamping an old-sequence | |
| # marker into the new window. Conflicts stay actionable. | |
| if [[ "${STALE}" != "true" && "${WINDOW:-none}" != "${LIVE_REARM_KEY}" && "${CONFLICT}" != "true" ]]; then | |
| STALE='true' | |
| echo "🫥 window superseded: selected under key '${WINDOW:-none}' but the live window is '${LIVE_REARM_KEY}' (re-armed while queued) — discarding without action or marker" | |
| fi | |
| if [[ -n "${LIVE_EVAL_WM}" && "${CONFLICT}" != "true" ]] \ | |
| && { [[ "${LIVE_EVAL_WM}" > "${WATERMARK}" ]] || [[ "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]] \ | |
| || { [[ -n "${LIVE_RED_HEAD}" ]] && [[ "${LIVE_RED_HEAD}" == "${CHECKED_OUT_HEAD}" ]]; }; }; then | |
| LIVE_NEW="$(jq -rs \ | |
| --arg wm "${LIVE_EVAL_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson trust "${TRUSTED_ASSOC}" ' | |
| (.[0] | map(select((.submitted_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED"))) | length) | |
| + (.[1] | map(select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)) | length) | |
| + (.[2] | map(select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | |
| | select((.body // "") | test("^\\s*@qwen-code /") | not)) | length) | |
| + (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) | |
| | select((.completedAt // .updatedAt // "") > $wm)) | length)' \ | |
| "${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json")" | |
| if [[ "${LIVE_NEW}" == "0" ]]; then | |
| STALE='true' | |
| echo "🫥 stale duplicate target: a sibling run already evaluated through ${LIVE_EVAL_WM} (round ${LIVE_MAX_ROUND}) and nothing is newer — discarding without action or marker" | |
| fi | |
| fi | |
| # A NON-stale run must still not replay a sibling's work: if live | |
| # markers advanced past the matrix snapshot, adopt the live | |
| # watermark (so the renderers below list only feedback the fleet | |
| # has not yet evaluated) and the live round (so the next marker | |
| # continues the live sequence instead of double-writing a round). | |
| if [[ "${STALE}" != "true" && -n "${LIVE_EVAL_WM}" && "${LIVE_EVAL_WM}" > "${WATERMARK}" && "${LIVE_EVAL_WM}" != "9999-12-31T23:59:59Z" ]]; then | |
| echo "⏩ adopting live watermark ${LIVE_EVAL_WM} (matrix had ${WATERMARK})" | |
| WATERMARK="${LIVE_EVAL_WM}" | |
| fi | |
| if [[ "${STALE}" != "true" && "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]]; then | |
| echo "⏩ adopting live round ${LIVE_MAX_ROUND} (matrix had ${ROUND})" | |
| ROUND="${LIVE_MAX_ROUND}" | |
| fi | |
| # The live round may already sit at the hard cap (a sibling consumed | |
| # the final round after this target was emitted). Running would do | |
| # round MAX+1 work and write a second capped marker, concealing the | |
| # cap — the scan itself skips capped PRs before even looking at | |
| # conflicts, so mirror that and discard. | |
| if [[ "${STALE}" != "true" && "${ROUND}" -ge "${MAX_ROUNDS}" ]]; then | |
| STALE='true' | |
| echo "⛔ live round ${ROUND} already at MAX_ROUNDS (${MAX_ROUNDS}) — discarding without action or marker" | |
| fi | |
| CRITICAL_ONLY='false' | |
| if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then | |
| CRITICAL_ONLY='true' | |
| fi | |
| # Which trusted humans have exhausted their per-window regular | |
| # feedback budget (see CRITICAL_ONLY_HUMAN_BATCHES). A batch is | |
| # COUNTED only when a Critical-only round actually consumed it: | |
| # feedback items are bucketed into the (prev marker ts, marker ts] | |
| # span that evaluated them, spans are kept only for markers that | |
| # ran in Critical-only territory (acted rounds numbered past the | |
| # threshold, no-change rounds at it), and an author needs >= K | |
| # distinct consumed spans to land here. Fresh, not-yet-evaluated | |
| # feedback never counts against its own author, and everything is | |
| # window-scoped so a /retry resets the budget with the window. | |
| # Only feedback the deferred renderer below would actually defer is | |
| # counted: Critical-tagged items, Request changes / APPROVED reviews, | |
| # and inline comments rooted at a Critical comment or attached to a | |
| # Request changes review are never deferrable, so they must not burn | |
| # an author's budget — the item filter mirrors those predicates. | |
| OVER_BUDGET_AUTHORS='[]' | |
| if [[ "${CRITICAL_ONLY}" == "true" ]]; then | |
| OVER_BUDGET_AUTHORS="$(jq -n \ | |
| --arg key "${LIVE_REARM_KEY}" --arg ab "${AUTOFIX_BOT}" --arg rb "${REVIEW_BOT}" \ | |
| --argjson trust "${TRUSTED_ASSOC}" \ | |
| --argjson critical_after "${CRITICAL_ONLY_AFTER_ROUND}" \ | |
| --argjson k "${CRITICAL_ONLY_HUMAN_BATCHES}" \ | |
| --slurpfile rv "${WORKDIR}/rv.json" --slurpfile rc "${WORKDIR}/rc.json" --slurpfile ic "${WORKDIR}/ic.json" ' | |
| ([ ($ic | add)[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") | |
| | [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[] | |
| | {ts: .[0], acted: .[1], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ] | |
| | map(select(.win == $key) | select(.ts != "9999-12-31T23:59:59Z")) | |
| | sort_by(.at)) as $ms | |
| | ([ range(0; ($ms | length)) as $i | |
| | ($ms[$i] | |
| | select((.acted == "true" and .round > $critical_after) or (.acted == "false" and .round >= $critical_after)) | |
| | {lo: (if $i == 0 then "" else ($ms[$i - 1].ts) end), hi: .ts}) ]) as $spans | |
| | ($rv | add) as $reviews | |
| | ($rc | add) as $comments | |
| | ([ $reviews[] | |
| | select((.state // "") == "COMMENTED") | |
| | select(((.body // "") | contains("**[Critical]**")) | not) | |
| | {at: (.submitted_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ] | |
| + [ $comments[] | |
| | select(( | |
| ((.body // "") | contains("**[Critical]**")) | |
| or ((.in_reply_to_id // null) as $root | |
| | $root != null | |
| and any($comments[]; .id == $root and ((.body // "") | contains("**[Critical]**")))) | |
| or ((.pull_request_review_id // null) as $review | |
| | $review != null | |
| and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))) | |
| ) | not) | |
| | {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ] | |
| + [ ($ic | add)[] | |
| | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | |
| | select((.body // "") | test("^\\s*@qwen-code /") | not) | |
| | select(((.body // "") | contains("**[Critical]**")) | not) | |
| | {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ]) | |
| | map(select(.login != "" and .login != $ab and .login != $rb) | select(.assoc | IN($trust[]))) | |
| | [ .[] | . as $c | |
| | ([ $spans[] | select($c.at > .lo and $c.at <= .hi) ] | .[0] // empty) | |
| | {login: $c.login, span: .hi} ] | |
| | group_by(.login) | |
| | map(select((map(.span) | unique | length) >= $k) | .[0].login) | |
| ' || echo '[]')" | |
| [[ -z "${OVER_BUDGET_AUTHORS}" ]] && OVER_BUDGET_AUTHORS='[]' | |
| [[ "${OVER_BUDGET_AUTHORS}" != '[]' ]] && echo "🚦 regular-feedback budget exhausted this window for: $(jq -r 'join(", ")' <<< "${OVER_BUDGET_AUTHORS}")" | |
| fi | |
| echo "stale=${STALE}" >> "${GITHUB_OUTPUT}" | |
| echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}" | |
| rm -f "${WORKDIR}/deferred-feedback.md" | |
| if [[ "${CRITICAL_ONLY}" == "true" ]]; then | |
| PR_URL="https://github.com/${REPO}/pull/${PR}" | |
| { | |
| echo '## Deferred non-Critical feedback' | |
| echo | |
| echo "Critical-only mode is active after ${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used ${CRITICAL_ONLY_HUMAN_BATCHES} regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (\`@qwen-code /retry\` starts a fresh counting window.)" | |
| echo | |
| jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" ' | |
| .[] | |
| | select((.submitted_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[]))) | |
| | select((.state // "") == "COMMENTED") | |
| | select(((.body // "") | contains("**[Critical]**")) | not) | |
| | "- Review by @\(.user.login): \(.html_url // $pr_url)"' \ | |
| "${WORKDIR}/rv.json" | |
| jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" \ | |
| --slurpfile reviews "${WORKDIR}/rv.json" ' | |
| add as $comments | |
| | ($reviews | add) as $reviews | |
| | $comments[] | |
| | select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[]))) | |
| | select(( | |
| ((.body // "") | contains("**[Critical]**")) | |
| or ((.in_reply_to_id // null) as $root | |
| | $root != null | |
| and any($comments[]; | |
| .id == $root | |
| and ((.body // "") | contains("**[Critical]**")))) | |
| or ((.pull_request_review_id // null) as $review | |
| | $review != null | |
| and any($reviews[]; | |
| .id == $review | |
| and ((.state // "") == "CHANGES_REQUESTED"))) | |
| ) | not) | |
| | "- Inline rc:\(.id) \(.path // "?"):\(.line // "?"): \(.html_url // $pr_url)"' \ | |
| "${WORKDIR}/rc.json" | |
| jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" ' | |
| .[] | |
| | select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[]))) | |
| | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | |
| | select((.body // "") | test("^\\s*@qwen-code /") | not) | |
| | select(((.body // "") | contains("**[Critical]**")) | not) | |
| | "- PR comment by @\(.user.login): \(.html_url // $pr_url)"' \ | |
| "${WORKDIR}/ic.json" | |
| if [[ "${OVER_BUDGET_AUTHORS}" != '[]' ]]; then | |
| echo | |
| jq -r '.[] | "- @" + . + " is at this window'"'"'s regular-feedback budget — to continue: tag **[Critical]**, submit a Request changes review, or comment `@qwen-code /retry` for a fresh window. / @" + . + " 本窗口常规反馈预算已用完——继续请标 **[Critical]**、提交 Request changes、或评论 `@qwen-code /retry` 开新窗口。"' <<< "${OVER_BUDGET_AUTHORS}" | |
| fi | |
| echo | |
| echo '<details>' | |
| echo '<summary>中文说明</summary>' | |
| echo | |
| echo "完成 ${CRITICAL_ONLY_AFTER_ROUND} 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 ${CRITICAL_ONLY_HUMAN_BATCHES} 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 \`@qwen-code /retry\` 可开启新的计数窗口。)" | |
| echo | |
| echo '</details>' | |
| } > "${WORKDIR}/deferred-feedback.md" | |
| fi | |
| # Render the actionable feedback into one prompt-ready file. | |
| { | |
| ISSUE_REF="" | |
| [[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})" | |
| echo "# Review feedback to triage on PR #${PR}${ISSUE_REF}" | |
| echo | |
| echo "Only feedback newer than the last evaluation (${WATERMARK}) from" | |
| echo "trusted maintainers or the automated reviewer is listed." | |
| echo | |
| echo "## Reviews" | |
| jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \ | |
| --argjson over "${OVER_BUDGET_AUTHORS}" ' | |
| .[] | |
| | select((.submitted_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) | |
| | select(($critical_only | not) | |
| or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not)) | |
| or (.state // "") == "CHANGES_REQUESTED" | |
| or ((.body // "") | contains("**[Critical]**"))) | |
| | "- [\(.state)] @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \ | |
| "${WORKDIR}/rv.json" | |
| echo | |
| echo "## Inline comments" | |
| jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \ | |
| --argjson over "${OVER_BUDGET_AUTHORS}" \ | |
| --slurpfile reviews "${WORKDIR}/rv.json" ' | |
| add as $comments | |
| | ($reviews | add) as $reviews | |
| | $comments[] | |
| | select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select(($critical_only | not) | |
| or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not)) | |
| or ((.body // "") | contains("**[Critical]**")) | |
| or ((.in_reply_to_id // null) as $root | |
| | $root != null | |
| and any($comments[]; | |
| .id == $root | |
| and ((.body // "") | contains("**[Critical]**")))) | |
| or ((.pull_request_review_id // null) as $review | |
| | $review != null | |
| and any($reviews[]; | |
| .id == $review | |
| and ((.state // "") == "CHANGES_REQUESTED")))) | |
| | "- [rc:\(.id)] \(.path // "?"):\(.line // "?") @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \ | |
| "${WORKDIR}/rc.json" | |
| echo | |
| echo "## Issue-level comments" | |
| jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ | |
| --argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \ | |
| --argjson over "${OVER_BUDGET_AUTHORS}" ' | |
| .[] | |
| | select((.created_at // "") > $wm) | |
| | select((.user.login // "") != $ab) | |
| | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | |
| | select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not) | |
| | select((.body // "") | test("^\\s*@qwen-code /") | not) | |
| | select(($critical_only | not) | |
| or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not)) | |
| or ((.body // "") | contains("**[Critical]**"))) | |
| | "- @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \ | |
| "${WORKDIR}/ic.json" | |
| if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then | |
| echo | |
| cat "${WORKDIR}/deferred-feedback.md" | |
| fi | |
| echo | |
| echo "## Failed checks" | |
| jq -r --arg wm "${WATERMARK}" ' | |
| .[] | |
| | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) | |
| | select((.completedAt // .updatedAt // "") > $wm) | |
| | "- \(((.name // .workflowName) // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \ | |
| "${WORKDIR}/checks.json" | |
| # Checks that turned red BEFORE the watermark and are STILL red. | |
| # The section above only shows checks that failed AFTER the | |
| # watermark, so a persistent red (the exact case the scan's | |
| # N_RED_NOW gate selects) would leave the agent with an empty | |
| # "Failed checks" section and no check name to reproduce. | |
| echo | |
| echo "## Still-red checks (persisting from before the last evaluation)" | |
| jq -r --arg wm "${WATERMARK}" ' | |
| .[] | |
| | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) | |
| | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) | |
| | select((.completedAt // .updatedAt // "") <= $wm) | |
| | "- \(((.name // .workflowName) // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \ | |
| "${WORKDIR}/checks.json" | |
| # If the LAST round ended in a gate rejection, show the agent WHY. | |
| # It is otherwise invisible on the retry: the reason lives in the | |
| # bot's own handoff comment, which the feedback filter above | |
| # (correctly) excludes, so the agent would re-read only the original | |
| # review points and re-make the same mistake - #7208 was handed to a | |
| # human over a two-character TS4111 fix its own compiler output had | |
| # already spelled out. | |
| LAST_REJECTION="$(jq -r --arg ab "${AUTOFIX_BOT}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-eval ts=")) ] | |
| | sort_by(.created_at) | last | .body // ""' "${WORKDIR}/ic.json" \ | |
| | sed -n '/<!-- autofix-gate-rejection-start -->/,/<!-- autofix-gate-rejection-end -->/p' \ | |
| | sed '1d;$d')" | |
| if [[ -n "${LAST_REJECTION}" ]]; then | |
| echo | |
| echo '## Your previous attempt was REJECTED by the verification gate' | |
| echo | |
| printf '%s\n' "${LAST_REJECTION}" | |
| fi | |
| # Time-budget exhaustions SINCE THE LAST SUCCESSFUL ROUND mean | |
| # the standard address-everything prompt is not converging at | |
| # this budget: re-running it unchanged just walks into the same | |
| # wall (#7929 burned three 50-minute timeouts that way, #7846 | |
| # two — each a full agent run with nothing pushed). From the | |
| # second attempt on, tell the agent to narrow. Counted since | |
| # the last pushed/no-change round, NOT cumulatively: a push | |
| # falsifies "not converging" and resets the count, so a recovered | |
| # PR stops seeing the warning; until a round pushes or no-ops it | |
| # fires on every failing round (gate rejections included) — | |
| # correctly, since nothing has converged yet. (The | |
| # BREAKER in the report step stays cumulative — a push does not | |
| # make the next timeout cheaper in budget terms.) Window-scoped | |
| # like every other census (LIVE_REARM_KEY is the live window), | |
| # so a re-arm clears it. The needle matches the emitted | |
| # headline verbatim: first lines can embed provider error text | |
| # (API_ERROR_DETAIL), so a loose phrase could count a model | |
| # error message as a timeout. | |
| PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" ' | |
| [ .[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-eval ")) | |
| | select(((.body // "") | contains("win=" + $key + " -->")) | |
| or ($key == "none" and (((.body // "") | contains("win=")) | not))) | |
| ] | sort_by(.created_at) | |
| | map((.body | gsub("\r"; "") | split("\n")[0])) | |
| | (map(test("Addressed the latest review feedback|no changes needed")) | rindex(true) // -1) as $lastok | |
| | [ .[($lastok + 1):][] | select(contains("AutoFix ran out of time before finishing")) ] | length' "${WORKDIR}/ic.json" 2> /dev/null || true)" | |
| if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]; then | |
| echo | |
| echo '## Budget warning: previous round(s) ran out of time' | |
| echo | |
| echo "${PRIOR_TIMEOUTS} round(s) since the last successful round exhausted the agent time budget before finishing anything." | |
| fi | |
| } > "${WORKDIR}/feedback.md" | |
| echo '--- feedback.md ---' | |
| cat "${WORKDIR}/feedback.md" | |
| # The agent below runs for up to 130 minutes and the verification gate adds | |
| # more, but nothing reaches the PR thread until "Push and report" at the | |
| # very end: a maintainer who just engaged takeover sees silence and cannot | |
| # tell a working round from a stuck one. The agent's output already | |
| # streams live to the Actions log, so publish that link up front. | |
| # Upserted by marker so one status comment per PR is EDITED each round | |
| # (edits notify nobody) rather than stacking a new comment against a | |
| # 100-round cap. Runs after prepare so a revalidated-away stale duplicate | |
| # never announces a round it will not run. Best-effort: a status post that | |
| # fails warns and continues — it must never cost the round. | |
| - name: 'Post autofix status comment' | |
| id: 'post_status' | |
| if: |- | |
| ${{ steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }} | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' | |
| RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' | |
| run: |- | |
| set -uo pipefail | |
| MARKER='<!-- autofix-status -->' | |
| ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" | |
| # ROUND counts rounds already DONE; every other message numbers the | |
| # round being performed (the report posts ROUND + 1). Match it, or | |
| # the same round carries two different numbers in one thread. | |
| if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then | |
| ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" | |
| fi | |
| BODY="$(printf '%s\n\n🔄 **AutoFix is working on this PR** — round %s/%s. [Watch live progress](%s); this round posts its report here when it finishes.\n\n<details>\n<summary>中文说明</summary>\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n\n</details>' \ | |
| "${MARKER}" "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}" \ | |
| "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}")" | |
| STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate | | |
| jq -rs --arg m "${MARKER}" --arg ab "${AUTOFIX_BOT}" \ | |
| '[ .[][] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains($m)) ] | last | .id // empty')" || | |
| STATUS_ID='' | |
| if [[ -n "${STATUS_ID}" ]]; then | |
| gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \ | |
| -f body="${BODY}" > /dev/null || | |
| echo "::warning::Failed to update the autofix status comment on PR #${PR}; continuing." | |
| else | |
| STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" \ | |
| -f body="${BODY}" --jq '.id')" || | |
| { | |
| STATUS_ID='' | |
| echo "::warning::Failed to post the autofix status comment on PR #${PR}; continuing." | |
| } | |
| fi | |
| # Hand the id to the finalize step so it does not repeat this scan. | |
| echo "comment_id=${STATUS_ID}" >> "${GITHUB_OUTPUT}" | |
| - name: 'Triage and address' | |
| id: 'address' | |
| # Skipped entirely for a stale duplicate target (see the live-watermark | |
| # revalidation in prepare) — no agent run, no marker, no comment. | |
| if: |- | |
| ${{ steps.prepare.outputs.stale != 'true' }} | |
| # Bound the agent below the job timeout so a runaway agent fails THIS | |
| # step (not the whole job), leaving the always() verify and report | |
| # steps time to run and post a handoff. A job-level timeout would | |
| # cancel those steps too and leave the loop silent. | |
| # | |
| # This step timeout is the BACKSTOP for a runaway that ignores the | |
| # agent's own timer; QWEN_TIMEOUT_MS below is the real budget. | |
| # Invariant: budget <= backstop - margin, where the margin covers | |
| # the internal kill path (SIGTERM, 10s grace, SIGKILL, marker write). | |
| # | |
| # Measured on run 30646547838: | |
| # | |
| # setup (12 steps, ends at 'Post autofix status comment') 5-7m | |
| # Triage and address #8005 round 9 50m03s (its own timer) | |
| # #8211 12m45s | |
| # Verification gate #8211 22m48s | |
| # push + report + finalize 3-4s | |
| # | |
| # Setup runs in EARLIER steps, so it never competes with the agent | |
| # for this cap. Worst-case budget: | |
| # | |
| # setup 7 | |
| # Triage and address 130 (120 budget + 10 margin) | |
| # Verification gate 60 (2.6x the measured 22m48s) | |
| # Repair 20 | |
| # Repair verification 60 | |
| # report 3 | |
| # ------------------------------- | |
| # worst case 280 => job timeout 300, and the job runs | |
| # on ubuntu-latest, whose own ceiling | |
| # is 360. | |
| timeout-minutes: 130 | |
| env: | |
| PR: '${{ env.PR }}' | |
| ISSUE: '${{ env.ISSUE }}' | |
| OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}' | |
| OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}' | |
| OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| NO_PROXY: '127.0.0.1,localhost,::1' | |
| QWEN_HOME: '${{ runner.temp }}/qwen-autofix-review-home' | |
| # The primary attempt's real budget: 120m, with a 10-minute margin | |
| # under the 130-minute step backstop above. The margin covers the | |
| # internal kill path (SIGTERM, 10s grace, SIGKILL, marker write); | |
| # if the step cap fires first, `agent-timeout` is never written and | |
| # the report step misclassifies the round as a crash. | |
| # QWEN_AUTOFIX_TIMEOUT_MS can only LOWER the fallback without a code | |
| # change: the run block clamps it to the 7,200,000 ms ceiling | |
| # (BUDGET_CAP_MS, the fallback itself), so raising the budget still | |
| # requires editing this default, BUDGET_CAP_MS, and the step backstop, | |
| # while a misconfigured variable degrades to a warning, not a misreport. | |
| QWEN_TIMEOUT_MS: '${{ vars.QWEN_AUTOFIX_TIMEOUT_MS || 7200000 }}' | |
| CONFLICT: '${{ steps.prepare.outputs.conflict }}' | |
| BASE: 'main' | |
| SETTINGS_JSON: |- | |
| { | |
| "maxSessionTurns": 400, | |
| "coreTools": [ | |
| "read_file", | |
| "read_many_files", | |
| "glob", | |
| "search_file_content", | |
| "write_file", | |
| "run_shell_command(cat)", | |
| "run_shell_command(git add)", | |
| "run_shell_command(git checkout)", | |
| "run_shell_command(git commit)", | |
| "run_shell_command(git diff)", | |
| "run_shell_command(git log)", | |
| "run_shell_command(git merge)", | |
| "run_shell_command(git status)", | |
| "run_shell_command(ls)", | |
| "run_shell_command(mkdir)", | |
| "run_shell_command(npm run build)", | |
| "run_shell_command(npm run typecheck)", | |
| "run_shell_command(npm run lint)", | |
| "run_shell_command(npx vitest)", | |
| "run_shell_command(npm run generate:settings-schema)", | |
| "run_shell_command(pwd)" | |
| ], | |
| "tools": { | |
| "sandbox": "docker" | |
| } | |
| } | |
| run: |- | |
| rm -rf "${QWEN_HOME}" | |
| mkdir -p .qwen "${QWEN_HOME}" | |
| if [[ -z "${OPENAI_API_KEY:-}" ]]; then | |
| echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.' | |
| exit 1 | |
| fi | |
| printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json | |
| rm -f "${WORKDIR}/failure.md" | |
| # Prepare severed hooks for its PAT-bearing git ops; THIS step | |
| # holds no PAT, so the branch's own hooks may check the agent's | |
| # commits again. HONEST LIMIT: the model key (OPENAI_API_KEY) IS | |
| # forwarded into the docker sandbox by the CLI, and the agent's | |
| # job is to build/test the branch — so on a taken-over | |
| # human-authored PR, branch-controlled scripts can read that key. | |
| # This is an accepted, explicit consequence of takeover | |
| # (triage+-gated, in-repo branches only, whose authors are | |
| # write-capable collaborators); keep AUTOFIX_OPENAI_API_KEY a | |
| # low-privilege, quota-bounded, rotatable key. | |
| git config core.hooksPath .husky | |
| # Clamp the override to the budget ceiling: a repo variable past | |
| # 7,200,000 ms (120m) would arm the timer past the 130-minute step | |
| # backstop, the cap would fire first, and the round would be | |
| # misreported as a crash. Malformed values fall back to the same | |
| # ceiling (run-agent.mjs's own || handles the empty/NaN case). | |
| # The {1,8} width bound keeps 10# inside int64: a 19+ digit value | |
| # wraps negative in (( )) and slips past the comparison unclamped. | |
| # 10# forces base-10: a zero-padded value is octal in (( )) and would | |
| # error past the guard the same way. | |
| # A FLOOR, not just a ceiling — and the floor guards the likelier | |
| # mistake. Every comment here, the PR body and the operator message | |
| # all speak in MINUTES; this one variable wants MILLISECONDS. A | |
| # maintainer told to "raise the agent time budget" who sets | |
| # QWEN_AUTOFIX_TIMEOUT_MS=120 arms a 120 ms timer: every round | |
| # SIGTERMs instantly, writes agent-timeout, and reports "ran out of | |
| # time (timeout (120ms))" until TIMEOUT_WINDOW_CAP trips and AutoFix | |
| # stops on the PR — advising the human to raise the budget they just | |
| # raised, with no ::warning:: anywhere in that loop. 60000 rejects | |
| # every minutes-shaped value (1..999) and every 0/000, which the | |
| # bare regex admitted while the message claimed positivity. | |
| BUDGET_CAP_MS=7200000 | |
| BUDGET_FLOOR_MS=60000 | |
| if [[ ! "${QWEN_TIMEOUT_MS}" =~ ^[0-9]{1,8}$ ]] || | |
| (( 10#${QWEN_TIMEOUT_MS} < BUDGET_FLOOR_MS )) || | |
| (( 10#${QWEN_TIMEOUT_MS} > BUDGET_CAP_MS )); then | |
| echo "::warning::QWEN_TIMEOUT_MS=${QWEN_TIMEOUT_MS} is not an integer of MILLISECONDS in [${BUDGET_FLOOR_MS}, ${BUDGET_CAP_MS}] (120 means 120ms, not 120 minutes); clamping to ${BUDGET_CAP_MS}" | |
| QWEN_TIMEOUT_MS="${BUDGET_CAP_MS}" | |
| fi | |
| export QWEN_TIMEOUT_MS | |
| # Trusted staged copy in the mirrored layout — resolves | |
| # ../SKILL.md to the trusted staged SKILL, never the PR branch's. | |
| node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ | |
| --mode address-review \ | |
| --pr "${PR}" \ | |
| --issue "${ISSUE}" \ | |
| --workdir "${WORKDIR}" \ | |
| --conflict "${CONFLICT}" \ | |
| --base "${BASE}" | |
| - name: 'Verification gate' | |
| id: 'verify' | |
| if: |- | |
| ${{ always() && steps.prepare.outputs.stale != 'true' }} | |
| continue-on-error: true | |
| # Unbounded until now, and the largest consumer in the job (22m48s | |
| # measured on #8211). Left unbounded it eats the job timeout, and a | |
| # JOB timeout cancels the always() reporters — the silent round this | |
| # design exists to prevent. Bounded here it degrades to the ordinary | |
| # verification-failure path instead: continue-on-error keeps the job | |
| # alive, 'Finalize verification' sees an empty outcome, falls through | |
| # its case to exit 1, and the always() report step posts. | |
| timeout-minutes: 60 | |
| run: |- | |
| bash "${RUNNER_TEMP}/run-autofix-review-verification.sh" | |
| - name: 'Repair deterministic rejection' | |
| id: 'repair' | |
| if: |- | |
| ${{ always() && steps.verify.outputs.retryable == 'true' }} | |
| timeout-minutes: 20 | |
| env: | |
| PR: '${{ env.PR }}' | |
| ISSUE: '${{ env.ISSUE }}' | |
| OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}' | |
| OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}' | |
| OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| NO_PROXY: '127.0.0.1,localhost,::1' | |
| QWEN_HOME: '${{ runner.temp }}/qwen-autofix-review-home' | |
| QWEN_TIMEOUT_MS: '1080000' | |
| CONFLICT: '${{ steps.prepare.outputs.conflict }}' | |
| BASE: 'main' | |
| SETTINGS_JSON: |- | |
| { | |
| "maxSessionTurns": 400, | |
| "coreTools": [ | |
| "read_file", | |
| "read_many_files", | |
| "glob", | |
| "search_file_content", | |
| "write_file", | |
| "run_shell_command(cat)", | |
| "run_shell_command(git add)", | |
| "run_shell_command(git checkout)", | |
| "run_shell_command(git commit)", | |
| "run_shell_command(git diff)", | |
| "run_shell_command(git log)", | |
| "run_shell_command(git merge)", | |
| "run_shell_command(git status)", | |
| "run_shell_command(ls)", | |
| "run_shell_command(mkdir)", | |
| "run_shell_command(npm run build)", | |
| "run_shell_command(npm run typecheck)", | |
| "run_shell_command(npm run lint)", | |
| "run_shell_command(npx vitest)", | |
| "run_shell_command(npm run generate:settings-schema)", | |
| "run_shell_command(pwd)" | |
| ], | |
| "tools": { | |
| "sandbox": "docker" | |
| } | |
| } | |
| run: |- | |
| echo "attempted=true" >> "${GITHUB_OUTPUT}" | |
| if [[ -z "${OPENAI_API_KEY:-}" ]]; then | |
| echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.' | |
| exit 1 | |
| fi | |
| if [[ ! -s "${WORKDIR}/gate-rejection.md" ]]; then | |
| echo '::error::Retryable verification rejection has no gate-rejection.md.' | |
| exit 1 | |
| fi | |
| { | |
| cat "${WORKDIR}/feedback.md" | |
| if [[ -s "${WORKDIR}/address-summary.md" ]]; then | |
| echo | |
| echo '## Previous attempt summary' | |
| cat "${WORKDIR}/address-summary.md" | |
| fi | |
| echo | |
| echo '## Same-run verification repair' | |
| echo | |
| echo 'The previous commit was rejected by deterministic verification.' | |
| echo | |
| cat "${WORKDIR}/gate-rejection.md" | |
| } > "${WORKDIR}/feedback.retry.md" | |
| mv "${WORKDIR}/feedback.retry.md" "${WORKDIR}/feedback.md" | |
| rm -f \ | |
| "${WORKDIR}/address-summary.md" \ | |
| "${WORKDIR}/no-action.md" \ | |
| "${WORKDIR}/failure.md" \ | |
| "${WORKDIR}/handoff.md" \ | |
| "${WORKDIR}/gate-output.log" \ | |
| "${WORKDIR}/gate-rejection.md" \ | |
| "${WORKDIR}/agent-api-error" \ | |
| "${WORKDIR}/agent-api-error-kind" \ | |
| "${WORKDIR}/agent-timeout" \ | |
| "${WORKDIR}/resolved-comments.txt" \ | |
| "${WORKDIR}/comment-replies.json" | |
| rm -rf "${QWEN_HOME}" | |
| mkdir -p .qwen "${QWEN_HOME}" | |
| printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json | |
| git config core.hooksPath .husky | |
| node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \ | |
| --mode address-review \ | |
| --pr "${PR}" \ | |
| --issue "${ISSUE}" \ | |
| --workdir "${WORKDIR}" \ | |
| --conflict "${CONFLICT}" \ | |
| --base "${BASE}" | |
| - name: 'Repair verification gate' | |
| id: 'verify_repair' | |
| if: |- | |
| ${{ always() && steps.repair.outputs.attempted == 'true' }} | |
| continue-on-error: true | |
| # Same bound as the first pass, for the same reason. | |
| timeout-minutes: 60 | |
| run: |- | |
| bash "${RUNNER_TEMP}/run-autofix-review-verification.sh" | |
| - name: 'Finalize verification' | |
| id: 'final_verify' | |
| if: |- | |
| ${{ always() && steps.prepare.outputs.stale != 'true' }} | |
| env: | |
| FIRST_OUTCOME: '${{ steps.verify.outputs.outcome }}' | |
| FIRST_COMMITTED: '${{ steps.verify.outputs.committed }}' | |
| FIRST_VERIFIED_HEAD: '${{ steps.verify.outputs.verified_head }}' | |
| REPAIR_ATTEMPTED: '${{ steps.repair.outputs.attempted }}' | |
| REPAIR_OUTCOME: '${{ steps.verify_repair.outputs.outcome }}' | |
| REPAIR_COMMITTED: '${{ steps.verify_repair.outputs.committed }}' | |
| REPAIR_VERIFIED_HEAD: '${{ steps.verify_repair.outputs.verified_head }}' | |
| run: |- | |
| OUTCOME="${FIRST_OUTCOME}" | |
| COMMITTED="${FIRST_COMMITTED}" | |
| VERIFIED_HEAD="${FIRST_VERIFIED_HEAD}" | |
| if [[ "${REPAIR_ATTEMPTED}" == 'true' ]]; then | |
| OUTCOME="${REPAIR_OUTCOME}" | |
| COMMITTED="${REPAIR_COMMITTED:-${FIRST_COMMITTED}}" | |
| VERIFIED_HEAD="${REPAIR_VERIFIED_HEAD}" | |
| fi | |
| echo "outcome=${OUTCOME}" >> "${GITHUB_OUTPUT}" | |
| if [[ -n "${COMMITTED}" ]]; then | |
| echo "committed=${COMMITTED}" >> "${GITHUB_OUTPUT}" | |
| fi | |
| if [[ -n "${VERIFIED_HEAD}" ]]; then | |
| echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" | |
| fi | |
| case "${OUTCOME}" in | |
| fixed|noop) ;; | |
| *) exit 1 ;; | |
| esac | |
| - name: 'Show run artifacts' | |
| if: |- | |
| ${{ always() }} | |
| run: |- | |
| if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then | |
| git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true | |
| fi | |
| for f in feedback.md address-summary.md no-action.md failure.md handoff.md gate-rejection.md agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json pr.diff; do | |
| if [[ -f "${WORKDIR}/${f}" ]]; then | |
| echo "=============== ${f} ===============" | |
| cat "${WORKDIR}/${f}" | |
| echo | |
| fi | |
| done | |
| - name: 'Upload run artifacts' | |
| if: |- | |
| ${{ always() }} | |
| uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 | |
| with: | |
| name: 'autofix-review-pr-${{ matrix.target.pr }}' | |
| path: '${{ env.WORKDIR }}/' | |
| if-no-files-found: 'ignore' | |
| - name: 'Push and report' | |
| if: |- | |
| ${{ always() && needs.route.outputs.dry_run != 'true' && (steps.final_verify.outputs.outcome == 'fixed' || steps.final_verify.outputs.outcome == 'noop') }} | |
| env: | |
| # CI_DEV_BOT_PAT (the qwen-code-dev-bot PAT) pushes the branch and | |
| # posts the report as qwen-code-dev-bot, the same identity that opened | |
| # the PR. The default GITHUB_TOKEN cannot do either on a bot-owned PR | |
| # in a way that re-triggers CI. | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| OUTCOME: '${{ steps.final_verify.outputs.outcome }}' | |
| CONFLICT: '${{ steps.prepare.outputs.conflict }}' | |
| NEWEST: '${{ steps.prepare.outputs.newest }}' | |
| EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' | |
| # Surfaced in the report footer for diagnosis + attribution; a repo | |
| # variable (not a secret), already the agent's OPENAI_MODEL. | |
| MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' | |
| VERIFIED_HEAD: '${{ steps.final_verify.outputs.verified_head }}' | |
| run: |- | |
| # The head the agent actually evaluated — captured in prepare before | |
| # any mutation, not the report-time remote head (which can move | |
| # during the run). Empty when prepare exited early, which matches | |
| # no marker and keeps reds visible — fail-open. | |
| REPORT_HEAD="${CHECKED_OUT_HEAD}" | |
| # Prepare may have adopted a sibling's live round; the matrix value | |
| # would double-write that round's marker. | |
| ROUND="${EFFECTIVE_ROUND:-${ROUND}}" | |
| MODEL_DISPLAY="${MODEL:-default}" | |
| if [[ -z "${GITHUB_TOKEN}" ]]; then | |
| echo '::error::CI_DEV_BOT_PAT is required to push and report as qwen-code-dev-bot.' | |
| exit 1 | |
| fi | |
| api_error_file="$(mktemp)" | |
| if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}" | |
| if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| if [[ "${OUTCOME}" == "fixed" ]]; then | |
| NEXT_ROUND="$(( ROUND + 1 ))" | |
| git config --local --unset-all http.https://github.com/.extraheader || true | |
| # This step carries the PAT; the branch carries PR-controlled | |
| # .husky hooks (hooksPath was pointed there so the AGENT's | |
| # commits get checked). A pre-push hook would execute that code | |
| # with the PAT in env — sever hooks entirely before pushing. | |
| git config core.hooksPath /dev/null | |
| # Authenticate push/fetch with a one-shot, host-scoped credential | |
| # helper via a git_auth wrapper (see Publish PR) — nothing lands | |
| # in .git/config, argv holds only the ${GITHUB_TOKEN} reference. | |
| git_auth() { git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' "$@"; } | |
| if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then | |
| # Push back to the FORK branch via allow-edits (PAT has push | |
| # rights on the upstream, which GitHub extends to the fork's | |
| # PR branch when the author ticked the box). | |
| PUSH_URL="https://github.com/${HEAD_REPO}.git" | |
| else | |
| PUSH_URL="https://github.com/${REPO}.git" | |
| fi | |
| # Salvage a race-lost push instead of discarding the run. The | |
| # per-PR head-write concurrency group serialises THIS repo's | |
| # workflows, but it cannot stop the PR author (or anything on the | |
| # fork side) pushing during the agent's ~120-minute window. The | |
| # stated budget widened it from ~50m, so a race-lost push is that | |
| # much likelier and the retry loop below stays bounded at 3 merges. | |
| # Observed twice in one day (#7983, #7985): a one-shot push died | |
| # `fetch first` and a full verified agent run was thrown away. | |
| # On rejection, fetch the moved head and MERGE it into the local | |
| # line (merge, not rebase: the agent's own conflict-resolution | |
| # rounds create merge commits, and a rebase would flatten them | |
| # and can silently re-introduce the conflicts it resolved). The | |
| # merge result descends from the remote head, so the retried push | |
| # is a fast-forward. A genuine content conflict aborts and falls | |
| # through to the existing failure path — same as today. | |
| PUSH_RACE_MERGED='false' | |
| for push_attempt in 1 2 3; do | |
| if git_auth push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then | |
| break | |
| fi | |
| if [[ "${push_attempt}" == 3 ]]; then | |
| echo "::error::push rejected ${push_attempt} times; giving up" | |
| exit 1 | |
| fi | |
| echo "⚠️ push rejected (attempt ${push_attempt}) — branch moved during the run; merging the new head and retrying" | |
| if ! git_auth fetch "${PUSH_URL}" "refs/heads/${BRANCH}"; then | |
| echo "::error::could not fetch the moved head (attempt ${push_attempt}) — cannot salvage this push" | |
| exit 1 | |
| fi | |
| # The disclosure flag keys on HEAD actually advancing: a push | |
| # can fail transiently (upload timeout, 503) with the branch | |
| # unmoved, and the merge then no-ops "Already up to date" — | |
| # flagging that would tell the reviewer to re-check mid-run | |
| # commits that never existed. | |
| PRE_MERGE_HEAD="$(git rev-parse HEAD)" | |
| if ! git -c user.name="${AUTOFIX_BOT}" \ | |
| -c user.email="${AUTOFIX_BOT}@users.noreply.github.com" \ | |
| merge --no-edit FETCH_HEAD; then | |
| git merge --abort || true | |
| echo "::error::the commits pushed during the run conflict with this fix — handing off instead of overwriting either side" | |
| exit 1 | |
| fi | |
| if [[ "$(git rev-parse HEAD)" != "${PRE_MERGE_HEAD}" ]]; then | |
| PUSH_RACE_MERGED='true' | |
| fi | |
| done | |
| CAN_RESOLVE_THREADS='false' | |
| if [[ -s "${WORKDIR}/resolved-comments.txt" ]]; then | |
| LOCAL_PUSHED_HEAD="$(git rev-parse HEAD)" | |
| if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then | |
| echo "::warning::skipping review-thread resolution because the pushed head includes commits merged after deterministic verification" | |
| elif [[ -z "${VERIFIED_HEAD}" || "${LOCAL_PUSHED_HEAD}" != "${VERIFIED_HEAD}" ]]; then | |
| echo "::warning::skipping review-thread resolution because the pushed head is not the exact deterministically verified commit" | |
| elif LIVE_PR_HEAD="$(gh pr view "${PR}" --repo "${REPO}" --json headRefOid --jq '.headRefOid // ""' 2> /dev/null)" && | |
| [[ -n "${LIVE_PR_HEAD}" && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" ]]; then | |
| CAN_RESOLVE_THREADS='true' | |
| else | |
| echo "::warning::skipping review-thread resolution because the live PR head could not be proven equal to the deterministically verified commit" | |
| fi | |
| fi | |
| # Resolve the review threads whose findings the agent actually | |
| # IMPLEMENTED, so a human re-reviewing sees only what is still open | |
| # instead of re-reading every thread to work out what was handled. | |
| # The agent cannot do this itself - its sandbox carries no token - | |
| # so it records the inline-comment ids it implemented and this step, | |
| # which already holds the PAT, maps each to its thread. Findings it | |
| # DECLINED or deferred are deliberately left open. Best-effort | |
| # throughout: a resolve failure must never fail a good push. | |
| # Both this resolve block and the reply block below map an | |
| # inline-comment id to its review thread, so the threads are | |
| # fetched once here and shared. Hoisted above both so a round that | |
| # only replies (no resolved-comments.txt) still has them. | |
| # first-100 page cap: a comment in a thread past this page is not | |
| # mapped, and each block falls back to the id as given. | |
| if [[ -s "${WORKDIR}/resolved-comments.txt" || -s "${WORKDIR}/comment-replies.json" ]]; then | |
| THREADS_RAW="$(gh api graphql -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f query=' | |
| query($owner:String!,$name:String!,$pr:Int!){ | |
| repository(owner:$owner,name:$name){ | |
| pullRequest(number:$pr){ | |
| reviewThreads(first:100){nodes{id isResolved comments(first:100){nodes{databaseId}}} pageInfo{hasNextPage}} | |
| } | |
| } | |
| }' --jq '(.data.repository.pullRequest.reviewThreads // {nodes:[]})' 2> /dev/null || echo '{"nodes":[]}')" | |
| THREADS_JSON="$(jq '.nodes' <<< "${THREADS_RAW}")" | |
| if [[ "$(jq -r '.pageInfo.hasNextPage // false' <<< "${THREADS_RAW}")" == "true" ]]; then | |
| echo "::warning::PR has more than 100 review threads; threads past the first page will not be resolved or answered in-thread" | |
| fi | |
| fi | |
| if [[ "${CAN_RESOLVE_THREADS}" == 'true' ]]; then | |
| CONFIRMED_RESOLVED_N=0 | |
| read_thread_guard() { | |
| gh api graphql -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f threadId="${1}" -f query=' | |
| query($owner:String!,$name:String!,$pr:Int!,$threadId:ID!){ | |
| repository(owner:$owner,name:$name){pullRequest(number:$pr){headRefOid}} | |
| node(id:$threadId){... on PullRequestReviewThread{isResolved}} | |
| }' --jq '[.data.repository.pullRequest.headRefOid // "", .data.node.isResolved] | @tsv' | |
| } | |
| while IFS= read -r rc_id || [[ -n "${rc_id}" ]]; do | |
| rc_id="${rc_id%$'\r'}" | |
| rc_id="${rc_id#rc:}" | |
| [[ "${rc_id}" =~ ^[0-9]+$ ]] || continue | |
| thread_id="$(jq -r --argjson id "${rc_id}" \ | |
| 'map(select(.isResolved | not) | |
| | select(any(.comments.nodes[]; .databaseId == $id))) | |
| | .[0].id // ""' <<< "${THREADS_JSON}")" | |
| if [[ -z "${thread_id}" ]]; then | |
| echo "::warning::comment ${rc_id} matched no open review thread" | |
| continue | |
| fi | |
| if ! IFS=$'\t' read -r LIVE_PR_HEAD THREAD_IS_RESOLVED < <(read_thread_guard "${thread_id}" 2> /dev/null) || | |
| [[ -z "${LIVE_PR_HEAD}" || "${LIVE_PR_HEAD}" != "${VERIFIED_HEAD}" ]]; then | |
| echo "::warning::stopping review-thread resolution because the live PR head moved before resolving comment ${rc_id}" | |
| break | |
| elif [[ "${THREAD_IS_RESOLVED}" == 'true' ]]; then | |
| echo "::warning::comment ${rc_id} was resolved by another actor before this round could resolve it" | |
| continue | |
| elif [[ "${THREAD_IS_RESOLVED}" != 'false' ]]; then | |
| echo "::warning::stopping review-thread resolution because the state of comment ${rc_id} could not be proven" | |
| break | |
| fi | |
| RESOLVE_SUCCEEDED='false' | |
| if gh api graphql -f threadId="${thread_id}" -f query=' | |
| mutation($threadId:ID!){ | |
| resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}} | |
| }' > /dev/null 2>&1; then | |
| RESOLVE_SUCCEEDED='true' | |
| fi | |
| POST_GUARD_OK='false' | |
| if IFS=$'\t' read -r LIVE_PR_HEAD THREAD_IS_RESOLVED < <(read_thread_guard "${thread_id}" 2> /dev/null); then | |
| POST_GUARD_OK='true' | |
| fi | |
| if [[ "${POST_GUARD_OK}" == 'true' && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" && "${THREAD_IS_RESOLVED}" == 'true' ]]; then | |
| if [[ "${RESOLVE_SUCCEEDED}" != 'true' ]]; then | |
| echo "::warning::comment ${rc_id} is resolved after an unsuccessful mutation command; another actor or a lost response may be responsible" | |
| fi | |
| CONFIRMED_RESOLVED_N=$(( CONFIRMED_RESOLVED_N + 1 )) | |
| elif [[ "${POST_GUARD_OK}" == 'true' && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" && "${THREAD_IS_RESOLVED}" == 'false' && "${RESOLVE_SUCCEEDED}" == 'false' ]]; then | |
| echo "::warning::could not resolve the review thread for comment ${rc_id}" | |
| else | |
| echo "::warning::the live PR head or thread state could not be proven after resolving comment ${rc_id}; stopping review-thread resolution" | |
| break | |
| fi | |
| done < "${WORKDIR}/resolved-comments.txt" | |
| echo "🧵 confirmed ${CONFIRMED_RESOLVED_N} selected review thread(s) resolved while the verified head remained live" | |
| fi | |
| # The mirror of the resolve above: a finding the agent did NOT | |
| # resolve keeps its thread open, and this answers it IN that thread. | |
| # Without it the reason sits only in the round summary, so the | |
| # reviewer who opens the still-open thread sees silence and cannot | |
| # tell their finding was read. Same neutralisation as the summary | |
| # body — a reply is model output posted verbatim under the bot | |
| # identity, so it could otherwise smuggle a forged control marker. | |
| # Best-effort: a reply failure must never fail a good push. | |
| if [[ -s "${WORKDIR}/comment-replies.json" ]] && | |
| jq -e 'type == "array"' "${WORKDIR}/comment-replies.json" > /dev/null 2>&1; then | |
| REPLIED_N=0 | |
| while IFS=$'\t' read -r rc_id reply_b64; do | |
| [[ "${rc_id}" =~ ^[0-9]+$ && -n "${reply_b64}" ]] || continue | |
| # A finding cannot be both resolved and replied to; the resolve | |
| # block above already closed anything in resolved-comments.txt, | |
| # so skip it here rather than answer a thread we just resolved. | |
| # Match tolerates the rc: prefix and a trailing CR, as the | |
| # resolve block's own parsing does. | |
| if [[ -f "${WORKDIR}/resolved-comments.txt" ]] && | |
| tr -d '\r' < "${WORKDIR}/resolved-comments.txt" | | |
| grep -qxE "(rc:)?${rc_id}"; then | |
| continue | |
| fi | |
| REPLY_BODY="$(base64 -d <<< "${reply_b64}" | sed 's/<!--/<!\\-\\-/g')" | |
| [[ -n "${REPLY_BODY}" ]] || continue | |
| # GitHub rejects a reply aimed at another reply ("Replies to | |
| # replies are not supported"), and rc_id can itself be a reply | |
| # id — the feedback step lists every review comment, replies | |
| # included. Map to the thread's top-level comment (the one | |
| # valid target), falling back to rc_id when the thread is past | |
| # the first-100 page cap and so absent from THREADS_JSON. | |
| root_id="$(jq -r --argjson id "${rc_id}" \ | |
| 'map(select(any(.comments.nodes[]; .databaseId == $id))) | |
| | .[0].comments.nodes[0].databaseId // $id' <<< "${THREADS_JSON}")" | |
| if gh api "repos/${REPO}/pulls/${PR}/comments/${root_id}/replies" \ | |
| -f body="${REPLY_BODY}" > /dev/null 2>&1; then | |
| REPLIED_N=$(( REPLIED_N + 1 )) | |
| else | |
| echo "::warning::could not reply to review comment ${rc_id}" | |
| fi | |
| done < <(jq -r '.[] | select(.id != null and .body != null) | |
| | [(.id | tostring), (.body | @base64)] | @tsv' \ | |
| "${WORKDIR}/comment-replies.json" 2> /dev/null || true) | |
| echo "🧵 replied on ${REPLIED_N} thread(s) the agent left open" | |
| fi | |
| { | |
| echo "🤖 Addressed the latest review feedback (round ${NEXT_ROUND}/${MAX_ROUNDS}). What changed, and what I pushed back on: · 已处理最新评审反馈(第 ${NEXT_ROUND}/${MAX_ROUNDS} 轮)。改动内容与我反驳保留之处如下:" | |
| echo | |
| # Neutralize the comment-opening token itself: model output | |
| # posted verbatim under the bot identity could smuggle a forged | |
| # control marker ('<!-- autofix-eval …') the scanners would | |
| # trust. Token-breaking is LINE-INDEPENDENT — a strip like | |
| # 's/<!--[^>]*-->//' misses a marker whose --> sits on another | |
| # line, and jq scan() matches across newlines. The backslashes | |
| # render away in markdown, so the visible text is unchanged. | |
| sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/address-summary.md" | |
| if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then | |
| echo | |
| sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/deferred-feedback.md" | |
| fi | |
| echo | |
| echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。' || echo 'no conflict with main. · 与 main 无冲突。')" | |
| if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then | |
| echo | |
| echo "⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。" | |
| fi | |
| echo | |
| echo "Re-review when you have a moment. After round ${MAX_ROUNDS} this bot stops and leaves the PR for a human. · 有空请复审;第 ${MAX_ROUNDS} 轮后本 bot 停止并将 PR 交给人工。" | |
| echo | |
| echo "---" | |
| echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" | |
| echo | |
| echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->" | |
| echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->" | |
| } > "${WORKDIR}/report.md" | |
| STATUS="pushed (round ${NEXT_ROUND}/${MAX_ROUNDS})" | |
| else | |
| # noop: evaluated, nothing worth doing. Report once and advance the | |
| # watermark so the next scan does not re-evaluate the same feedback. | |
| { | |
| echo "🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:" | |
| echo | |
| sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/no-action.md" | |
| if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then | |
| echo | |
| sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/deferred-feedback.md" | |
| fi | |
| echo | |
| echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicts with main (no review fix needed, but a rebase/merge is required before merge). · 与 main 有冲突(无需评审修复,但合并前需 rebase/merge)。' || echo 'no conflict with main. · 与 main 无冲突。')" | |
| echo | |
| echo "---" | |
| echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" | |
| echo | |
| echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->" | |
| echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->" | |
| } > "${WORKDIR}/report.md" | |
| STATUS="no action needed" | |
| fi | |
| gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" | |
| # Takeover milestone digest — roughly every 10 rounds. The takeover | |
| # cap (100) bounds runaway but says nothing about when a human | |
| # should step in: #7469 ground to round 12 over 7 days with the | |
| # only "this is burning budget" signal buried in Actions logs. | |
| # Once 10+ rounds accumulate since the last digest, surface a | |
| # window-scoped census on the PR so the maintainer who engaged it | |
| # can decide: keep going, split the PR, or release. A SEPARATE | |
| # comment with its OWN marker and WITHOUT the autofix-eval marker: | |
| # every census (round, consec, watermark) selects on autofix-eval, | |
| # so this comment is invisible to all of them, and the feedback | |
| # filters drop bot comments, so the agent never sees it either. | |
| # Best-effort: a digest failure must never fail a good push. | |
| if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]] \ | |
| && [[ "${NEXT_ROUND}" -ge 10 && -f "${WORKDIR}/ic.json" ]]; then | |
| # Crossing trigger, not an equality test: failure rounds also | |
| # advance the round counter, so `push@9, crash@10, push@11` | |
| # would skip an exact %10 check forever — and a failure-heavy | |
| # PR is the very PR the digest exists for. Post on the first | |
| # PUSHED round once 10+ rounds have accumulated since the last | |
| # digest in THIS window (or since the window opened). | |
| MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' | |
| [ .[] | select((.user.login // "") == $ab) | (.body // "") | |
| | [ scan("<!-- autofix-milestone round=([0-9]+) win=([^ ]+) -->") ] | .[] | |
| | select(.[1] == $win) | (.[0] | tonumber) ] | |
| | max // 0' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)" | |
| if [[ "$(( NEXT_ROUND - MS_LAST ))" -ge 10 ]]; then | |
| WIN_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' | |
| [.[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-eval ")) | |
| | select( | |
| ((.body // "") | contains("win=" + $win + " -->")) | |
| or ($win == "none" and (((.body // "") | contains("win=")) | not)))] | |
| | sort_by(.created_at) | .[] | |
| | (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)" | |
| if [[ -z "${WIN_HEADS}" ]]; then | |
| # Reaching round 10+ with zero window markers means the | |
| # parse failed (prior markers must exist to be here) — a | |
| # fabricated all-zero census is worse than no digest. | |
| echo "::warning::milestone census found no window markers on #${PR}; skipping the digest" | |
| else | |
| N_PUSHED="$(grep -c 'Addressed the latest review feedback' <<< "${WIN_HEADS}" || true)" | |
| # This round's own marker was posted just above but ic.json | |
| # predates it — count it in by hand. | |
| N_PUSHED=$(( N_PUSHED + 1 )) | |
| N_NOOP="$(grep -c 'no changes needed' <<< "${WIN_HEADS}" || true)" | |
| # Needle matches the emitted headline verbatim — first | |
| # lines can embed provider error text. | |
| N_TIMEOUT="$(grep -c 'AutoFix ran out of time before finishing' <<< "${WIN_HEADS}" || true)" | |
| # Both wordings of the gate-rejection handoff, past and | |
| # present — the census must not silently zero when the | |
| # headline is reworded. | |
| N_REJECTED="$(grep -cE 'Could not (address the latest feedback|produce a passing fix)' <<< "${WIN_HEADS}" || true)" | |
| # Every other outcome (crash, model error, gate error, | |
| # infra) lands in a residual bucket: a window that burned | |
| # 80% of its budget on crashes must be the LOUDEST line in | |
| # the digest, not four zeros quieter than a healthy one. | |
| N_TOTAL=$(( $(grep -c . <<< "${WIN_HEADS}" || true) + 1 )) | |
| N_OTHER=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED )) | |
| (( N_OTHER < 0 )) && N_OTHER=0 | |
| # Base updates carry their own marker with no win= field; | |
| # their window is recovered by timestamp (the window key IS | |
| # the engage ack's created_at — 'none' means count all, | |
| # and the header says so). | |
| N_BASE="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' | |
| [.[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-base-updated -->")) | |
| | select($win == "none" or ((.created_at // "") > $win))] | |
| | length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)" | |
| WIN_DESC='in the current window' | |
| WIN_DESC_ZH='当前窗口' | |
| if [[ "${WINDOW:-none}" == 'none' ]]; then | |
| WIN_DESC='since the PR opened (no counting window yet)' | |
| WIN_DESC_ZH='自 PR 创建以来(尚无计数窗口)' | |
| fi | |
| if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '📊 Takeover milestone — round %s/%s, %s. Census: %s pushed fix(es), %s no-change review(s), %s timeout(s), %s rejected attempt(s), %s other round(s) (crash / model error / gate error / infra), %s base update(s).\n\nThis many rounds deserves a human look. Options: keep going (fine — nothing changes), split or reduce the PR if rounds keep accumulating, or release takeover (remove the `%s` label or comment `%s stop`). Management continues unchanged unless you act.\n\n<details>\n<summary>中文说明</summary>\n\n📊 接管里程碑 —— 第 %s/%s 轮(%s)。统计:推送修复 %s 次、审阅无需改动 %s 次、超时 %s 次、验证拒绝 %s 次、其他轮次(崩溃/模型错误/门错误/infra)%s 次、base 更新 %s 次。\n\n轮次到这个量值得人工看一眼。可选:继续(无需操作);若轮次持续累积,考虑拆分或缩减 PR;或释放接管(移除 `%s` 标签或评论 `%s stop`)。不操作则托管照常继续。\n\n</details>\n\n<!-- autofix-milestone round=%s win=%s -->' "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${WINDOW:-none}")"; then | |
| echo "📊 milestone digest posted on #${PR} (round ${NEXT_ROUND})" | |
| else | |
| echo "::warning::milestone digest failed to post on PR #${PR}; the round report above already landed" | |
| fi | |
| fi | |
| fi | |
| fi | |
| { | |
| ISSUE_REF="" | |
| [[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})" | |
| echo "### PR #${PR}${ISSUE_REF} — ${STATUS}" | |
| echo "- Base conflict: ${CONFLICT}" | |
| echo | |
| if [[ "${OUTCOME}" == "fixed" ]]; then | |
| cat "${WORKDIR}/address-summary.md" | |
| else | |
| cat "${WORKDIR}/no-action.md" | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| echo "💬 PR #${PR}: ${STATUS}" | |
| - name: 'Report dry-run / failure' | |
| if: |- | |
| ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }} | |
| env: | |
| OUTCOME: '${{ steps.final_verify.outputs.outcome }}' | |
| COMMITTED: '${{ steps.final_verify.outputs.committed }}' | |
| CONFLICT: '${{ steps.prepare.outputs.conflict }}' | |
| DRY_RUN: '${{ needs.route.outputs.dry_run }}' | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| NEWEST: '${{ steps.prepare.outputs.newest }}' | |
| JOB_STATUS: '${{ job.status }}' | |
| # 'skipped' when an EARLIER step failed before Prepare ran (base | |
| # install/build) — an infra/base failure, not the agent. | |
| # 'success'/'failure' when Prepare itself ran. Distinguishes a | |
| # transient pre-agent crash (retry) from a genuine agent crash. | |
| PREPARE_OUTCOME: '${{ steps.prepare.outcome }}' | |
| STALE: '${{ steps.prepare.outputs.stale }}' | |
| EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' | |
| MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' | |
| CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' | |
| run: |- | |
| # The head the agent actually evaluated — captured in prepare before | |
| # any mutation, not the report-time remote head (which can move | |
| # during the run). Empty when prepare exited early, which matches | |
| # no marker and keeps reds visible — fail-open. | |
| REPORT_HEAD="${CHECKED_OUT_HEAD}" | |
| ROUND="${EFFECTIVE_ROUND:-${ROUND}}" | |
| MODEL_DISPLAY="${MODEL:-default}" | |
| SUFFIX='' | |
| [[ "${DRY_RUN}" == "true" ]] && SUFFIX=' (dry-run, nothing pushed)' | |
| { | |
| ISSUE_REF="" | |
| [[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})" | |
| echo "### PR #${PR}${ISSUE_REF} — outcome=${OUTCOME:-unknown}${SUFFIX}" | |
| echo "- Base conflict: ${CONFLICT:-unknown}" | |
| echo | |
| for f in address-summary.md no-action.md failure.md handoff.md; do | |
| if [[ -s "${WORKDIR}/${f}" ]]; then | |
| echo "**${f}:**" | |
| cat "${WORKDIR}/${f}" | |
| echo | |
| fi | |
| done | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| # Leave a visible handoff + eval marker when the address did NOT publish a | |
| # result — a verify failure, or an agent/infra crash or timeout before the | |
| # verify gate ran. Without it the loop goes SILENT (no comment, no marker) | |
| # and the next scan re-targets the same feedback forever. | |
| # | |
| # SUPPRESS entirely once "Push and report" already handled this run | |
| # (OUTCOME fixed or noop). That step is also always()-gated and runs even | |
| # if a LATER always() step (e.g. artifact upload) fails the job; without | |
| # this guard, such a late failure would flip JOB_STATUS to failure and | |
| # post a contradictory acted=false handoff on top of the published fix. | |
| # (A genuine push failure leaves OUTCOME=fixed but writes no marker, so | |
| # the next scan simply retries — it does not need a handoff here.) | |
| # | |
| # SUPPRESS likewise for a stale-discarded run: it did no work, so a | |
| # late always()-step failure (e.g. artifact upload) must not turn a | |
| # deliberate no-comment/no-marker discard into a handoff that | |
| # consumes a round. | |
| POST_HANDOFF=false | |
| if [[ "${DRY_RUN}" != "true" && "${STALE:-}" != "true" && -n "${GITHUB_TOKEN:-}" && "${OUTCOME:-unknown}" != "fixed" && "${OUTCOME:-unknown}" != "noop" ]]; then | |
| if [[ "${OUTCOME:-unknown}" == "failed" || "${JOB_STATUS:-}" != "success" ]]; then | |
| POST_HANDOFF=true | |
| fi | |
| fi | |
| if [[ "${POST_HANDOFF}" == "true" ]]; then | |
| api_error_file="$(mktemp)" | |
| if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then | |
| api_error="$(tr '\r\n' ' ' < "${api_error_file}")" | |
| rm -f "${api_error_file}" | |
| echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}." | |
| exit 1 | |
| fi | |
| rm -f "${api_error_file}" | |
| echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}" | |
| if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then | |
| echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}." | |
| exit 1 | |
| fi | |
| # Attach the most actionable agent output. failure.md first (its | |
| # diagnosis; run-agent.mjs wraps it in a generic handoff.md, so prefer | |
| # failure.md). Then the agent's SUCCESS outputs: on the OUTCOME=failed | |
| # path where the agent committed a fix but a post-agent verify gate then | |
| # failed (most notably the schema-freshness gate), only | |
| # address-summary.md/no-action.md exist and "Push and report" is | |
| # skipped, so this handoff is their only route to the PR — otherwise the | |
| # comment would wrongly say "crashed or timed out". Generic notice only | |
| # if none exist. | |
| DETAIL_FILE='' | |
| for f in failure.md handoff.md address-summary.md no-action.md; do | |
| if [[ -s "${WORKDIR}/${f}" ]]; then DETAIL_FILE="${WORKDIR}/${f}"; break; fi | |
| done | |
| # run-agent.mjs drops this marker when qwen died on a model-side | |
| # [API Error: 4xx/5xx] (access denied, quota, a 5xx). The agent never | |
| # evaluated the feedback, so this is treated as a retry (sentinel ts, | |
| # no watermark advance) below — a model access/quota blip must not | |
| # strand the PR the way a real evaluated handoff would. | |
| API_ERROR_DETAIL='' | |
| API_ERROR_KIND='' | |
| if [[ -s "${WORKDIR}/agent-api-error" ]]; then | |
| # First line only, comment-opener escaped (agent stdout can echo | |
| # external PR-comment text and the marker regex spans '<!-- ... -->' | |
| # happily), and capped so a long span can't bloat the headline. | |
| # `cut -c` counts BYTES, so the cap can split a multi-byte | |
| # character - and the classifier deliberately matches CJK renders, | |
| # so a >200-byte Chinese error is a supported input, not a | |
| # hypothetical. iconv -c drops the dangling bytes so the headline | |
| # stays valid UTF-8; it EXITS 1 when it discards one, which under | |
| # this step's `set -eo pipefail` would abort before the marker and | |
| # the gh pr comment - hence the `|| true`, same as the sibling | |
| # publish site below. | |
| API_ERROR_DETAIL="$(head -n 1 "${WORKDIR}/agent-api-error" | sed 's/<!--/<!\\-\\-/g' | cut -c1-200 | iconv -f utf-8 -t utf-8 -c || true)" | |
| fi | |
| if [[ -s "${WORKDIR}/agent-api-error-kind" ]]; then | |
| API_ERROR_KIND="$(head -n 1 "${WORKDIR}/agent-api-error-kind" | tr -cd 'a-z')" | |
| fi | |
| # A timeout means the agent evaluated NOTHING before its budget ran | |
| # out (run-agent.mjs writes this signal). Routed to retry, not an | |
| # evaluated advance — same as a pre-verdict crash. Bounded by the | |
| # round cap and the consecutive-failure cap, so a PR that keeps | |
| # timing out still stops; a one-off (usually followed by a good | |
| # round) recovers on the next scan instead of stranding its feedback. | |
| AGENT_TIMEOUT='' | |
| if [[ -s "${WORKDIR}/agent-timeout" ]]; then | |
| AGENT_TIMEOUT="$(head -n 1 "${WORKDIR}/agent-timeout" | cut -c1-120 | iconv -f utf-8 -t utf-8 -c || true)" | |
| fi | |
| # If feedback was actually read (prepare ran), stamp its newest ts so | |
| # the watermark advances and the same feedback is not re-selected next | |
| # scan. If the crash happened before prepare, NEWEST is empty and the | |
| # watermark cannot advance — mark the round terminal (MAX_ROUNDS) so the | |
| # scan's max-round guard skips this PR instead of re-handing-off every | |
| # tick, without pretending the unread feedback was evaluated. The final | |
| # sentinel guards a cascading API failure that left WATERMARK empty too: | |
| # an empty ts= would not match the scan's `ts=([^ ]+)` regex, so the | |
| # terminal marker would be ignored and the PR re-handed-off. A far-future | |
| # ISO-8601 date is used (not a bare word) so it is both non-empty AND | |
| # sorts above any real timestamp in EVAL_WM's max, belt-and-suspenders | |
| # with the terminal round. | |
| # The gate declares its verdict explicitly (failed / noop / fixed). | |
| # An EMPTY outcome on a non-success job means it died BEFORE reaching | |
| # one - its own crash (a gate bug, an infra blip, a resolver error), | |
| # not a judgement on the agent's work. That must retry like any other | |
| # pre-verdict crash instead of advancing the watermark: the | |
| # nested-package ENOENT that stranded #7329/#7336 looked exactly like | |
| # a rejection, so a fix the agent had already written was discarded | |
| # and the PR sat idle until a human deleted the marker by hand. | |
| GATE_CRASHED=false | |
| if [[ -z "${OUTCOME}" && "${JOB_STATUS:-}" != 'success' ]]; then | |
| GATE_CRASHED=true | |
| fi | |
| MARK_TS="${NEWEST:-${WATERMARK:-9999-12-31T23:59:59Z}}" | |
| if [[ -n "${NEWEST:-}" ]]; then | |
| MARK_ROUND="$(( ROUND + 1 ))" | |
| if [[ -z "${DETAIL_FILE}" || -n "${API_ERROR_DETAIL}" || -n "${AGENT_TIMEOUT}" || "${GATE_CRASHED}" == 'true' ]]; then | |
| # Prepare ran (NEWEST is set) but no verdict was reached. Ways | |
| # that happens, and in ALL of them the agent evaluated NOTHING: | |
| # it produced no output at all (crashed before any verdict — a | |
| # staged runner that fails to boot), it died on a model | |
| # [API Error] (access/quota/5xx/transport), it TIMED OUT before | |
| # finishing, or the gate crashed after the agent wrote its | |
| # summary. So the watermark | |
| # must NOT advance past this feedback: an advance makes the next | |
| # scan see "nothing new" and never retry, stranding the PR on a | |
| # transient failure (an infra blip, a quota reset minutes away, a | |
| # model-access grant, a base-image bug fixed minutes later). | |
| # Stamp the sentinel ts (excluded from EVAL_WM) so the feedback | |
| # stays live and the next scan retries; the incremented round | |
| # still bounds retries before a terminal handoff, so a PERSISTENT | |
| # failure cannot loop forever. | |
| MARK_TS='9999-12-31T23:59:59Z' | |
| # Cause-aware wording, most specific first — a model error and a | |
| # gate crash each name the operator fix, while a bare no-output | |
| # crash points at a human. (The API clause runs first as | |
| # defense-in-depth: today run-agent writes failure.md on the | |
| # API-death path and the gate converts that to an explicit | |
| # outcome=failed, so GATE_CRASHED is false — but if the gate | |
| # ever changes, a model blip must not be reported as a gate | |
| # problem.) No Run log here — the report block below appends | |
| # it (avoid a duplicate). | |
| if [[ -n "${API_ERROR_DETAIL}" ]]; then | |
| CAUSE="could not reach the model — ${API_ERROR_DETAIL}" | |
| LAST_FIX="a maintainer should check the autofix model key/access, then re-arm" | |
| elif [[ -n "${AGENT_TIMEOUT}" ]]; then | |
| # A timeout evaluated nothing, so the feedback is unaddressed | |
| # and stays live for the retry. On a big / heavily-reviewed PR | |
| # this is usually a one-off; the last automatic attempt names | |
| # the real fix (split the PR or raise the budget). | |
| CAUSE="ran out of time before finishing (${AGENT_TIMEOUT})" | |
| LAST_FIX="a human should split the PR or raise the agent time budget and its step backstop, then re-arm" | |
| elif [[ -z "${DETAIL_FILE}" ]]; then | |
| CAUSE="crashed before it could evaluate the feedback" | |
| LAST_FIX="a human should take over this PR" | |
| else | |
| # A gate crash points the maintainer at the gate logs: the | |
| # agent's commit is discarded with the runner, but the | |
| # feedback watermark is preserved, so the retry re-attempts | |
| # the same feedback rather than skipping it. | |
| CAUSE="hit a verification-gate error before reaching a verdict" | |
| LAST_FIX="a maintainer should check the gate logs, then re-arm" | |
| fi | |
| # Budget retries by CAUSE. A transient 429/5xx self-heals, so | |
| # it keeps the full round budget. An auth/access error that | |
| # only a maintainer can fix never self-heals, and every attempt | |
| # costs an agent run AND a PR comment - on a takeover PR that | |
| # is ~100 comments over ~17h before the useful message lands. | |
| CAUSE_MAX="${MAX_ROUNDS}" | |
| if [[ "${API_ERROR_KIND}" == 'auth' && "${API_AUTH_MAX_ROUNDS}" -lt "${MAX_ROUNDS}" ]]; then | |
| CAUSE_MAX="${API_AUTH_MAX_ROUNDS}" | |
| fi | |
| # Only promise a retry when one will actually happen: at the | |
| # cap the next scan's round gate skips the PR, and the | |
| # cap-reached notice is takeover-only, so the final attempt | |
| # must say so itself. Clamp the displayed numerator: | |
| # MARK_ROUND counts ALL rounds in the window, so earlier real | |
| # attempts can push it past CAUSE_MAX — "attempt 4/3" reads | |
| # as a bug. | |
| DISPLAY_ROUND=$(( MARK_ROUND < CAUSE_MAX ? MARK_ROUND : CAUSE_MAX )) | |
| if [[ "${MARK_ROUND}" -lt "${CAUSE_MAX}" ]]; then | |
| HEADLINE="🤖 AutoFix ${CAUSE} (attempt ${DISPLAY_ROUND}/${CAUSE_MAX}) — it will retry on the next scan." | |
| else | |
| HEADLINE="🤖 AutoFix ${CAUSE} (attempt ${DISPLAY_ROUND}/${CAUSE_MAX}) — this was the last automatic attempt; ${LAST_FIX}." | |
| # Stamp the terminal round so the scan's max-round gate skips | |
| # this PR instead of re-handing-off every tick; the sentinel | |
| # ts keeps the feedback live for a re-arm once the key is | |
| # fixed. (No-op when CAUSE_MAX is already MAX_ROUNDS.) | |
| MARK_ROUND="${MAX_ROUNDS}" | |
| fi | |
| else | |
| # The gate ran and rejected the agent's fix (a build/test | |
| # failure). Before handing to a human, check whether the PR is | |
| # merely BEHIND main: a build that fails on something main | |
| # already changed — e.g. #7471's update-notifier, removed by | |
| # #7515, left its import unresolved on a stale branch — is a | |
| # stale-base failure, NOT the fix. If behind, merge main in and | |
| # retry: the next round builds against current main. After the | |
| # update the PR is current, so a genuine fix-failure next round | |
| # is no longer "behind" and falls through to the handoff below — | |
| # which self-limits this to ONE base-update. update-branch is a | |
| # CAS on the checked-out head; any API failure is fail-safe (fall | |
| # through to the handoff). This is the agent-gate sibling of the | |
| # scan's stale-base auto-update, which only sees PR status | |
| # checks, never the gate's own build. | |
| STALE_BASE_RETRY=false | |
| MAIN_HEAD_R="$(gh api "repos/${REPO}/commits/${DEFAULT_BRANCH:-main}" --jq '.sha' 2> /dev/null || echo '')" | |
| if [[ -n "${MAIN_HEAD_R}" && -n "${REPORT_HEAD}" ]]; then | |
| CMP_R="$(gh api "repos/${REPO}/compare/${MAIN_HEAD_R}...${REPORT_HEAD}" --jq '.status' 2> /dev/null || echo '')" | |
| if [[ "${CMP_R}" == 'behind' || "${CMP_R}" == 'diverged' ]] \ | |
| && gh api -X PUT "repos/${REPO}/pulls/${PR}/update-branch" -f expected_head_sha="${REPORT_HEAD}" > /dev/null 2>&1; then | |
| STALE_BASE_RETRY=true | |
| fi | |
| fi | |
| if [[ "${STALE_BASE_RETRY}" == 'true' ]]; then | |
| # Feedback stays live (sentinel ts) so the retry re-reads it | |
| # against the freshly-merged base; the incremented round still | |
| # bounds it, and the consecutive-failure exemption below keeps | |
| # this not-the-PR's-fault round from counting toward the cap. | |
| MARK_TS='9999-12-31T23:59:59Z' | |
| HEADLINE="🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind \`${DEFAULT_BRANCH:-main}\`, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human." | |
| else | |
| # Say what actually happens next. The old "A human should | |
| # take over this PR" read as a full release, but the loop | |
| # is NOT done with the PR: this feedback's watermark | |
| # advances (no automatic retry of THIS item), while | |
| # management continues for new feedback and base conflicts | |
| # — #7929 posted the old wording and then kept pushing | |
| # rounds, which read as a contradiction. | |
| # Name the gate ONLY when it actually ran: this branch is | |
| # reached for every outcome=failed verdict, but reject_fix | |
| # is the sole writer of gate-rejection.md — the failure.md / | |
| # dirty-tree / unchanged-branch / missing-summary paths made | |
| # no gate decision, so a blanket clause would repeat the very | |
| # wording-doesn't-match-behaviour bug this PR fixes. | |
| GATE_CLAUSE='' | |
| [[ -s "${WORKDIR}/gate-rejection.md" ]] && GATE_CLAUSE=' — the verification gate rejected the attempt' | |
| HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS})${GATE_CLAUSE}. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." | |
| fi | |
| fi | |
| elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then | |
| # NEWEST is empty because Prepare never RAN TO A VERDICT — an | |
| # earlier step failed or the job stopped before the agent started: | |
| # installing/building the trusted base, node setup. That | |
| # is infra or a broken base, NOT the agent, and it is usually | |
| # transient (a base build fixed minutes later, an ENOSPC runner). | |
| # Match on "not a real Prepare run" rather than 'skipped' alone, so | |
| # this also covers a CANCELLED job (outcome 'cancelled') and a job | |
| # that stopped before Prepare even entered the step context | |
| # (outcome ''): a concurrency/manual cancel is not the agent's | |
| # fault either, and 'cancelled' is a DISTINCT value from 'skipped' | |
| # — matching only 'skipped' would send a cancel to the terminal | |
| # branch below. Terminal here is wrong: a web-shell TS break on | |
| # main failed the base build across a whole scan batch and stranded | |
| # SIX healthy PRs terminally, including ones at round 11. Retry | |
| # instead — sentinel ts keeps the feedback live — but still | |
| # increment the round so a PERSISTENTLY broken base is bounded and | |
| # cannot loop forever. | |
| MARK_ROUND="$(( ROUND + 1 ))" | |
| MARK_TS='9999-12-31T23:59:59Z' | |
| if [[ "${MARK_ROUND}" -lt "${MAX_ROUNDS}" ]]; then | |
| HEADLINE="🤖 AutoFix could not start — a setup step failed (or the run was cancelled) before the agent ran, so no fix was attempted. This is normally a transient infra issue, a broken base build, or a cancelled run — not this PR. It will retry on the next scan." | |
| else | |
| MARK_ROUND="${MAX_ROUNDS}" | |
| HEADLINE="🤖 AutoFix could not start — reached the round cap (${MAX_ROUNDS}) because a setup step (base install/build) kept failing before the agent ran. This is infra or a persistently broken base, not this PR. Fix the base build, then comment \`${RETRY_COMMAND}\` to re-arm." | |
| fi | |
| else | |
| # Prepare RAN (outcome success/failure) but produced no feedback to | |
| # read — prepare itself crashed or timed out before emitting a | |
| # verdict. Mark | |
| # terminal so the scan skips (it can't advance the watermark | |
| # without a read); do NOT imply MAX_ROUNDS attempts were made when | |
| # zero rounds happened. The headline states the real recovery | |
| # (delete the marker) rather than promise a re-trigger the | |
| # max-round guard would ignore. | |
| MARK_ROUND="${MAX_ROUNDS}" | |
| HEADLINE="🤖 AutoFix could not start evaluation — it crashed or timed out before reading the feedback, so no fix was attempted. This PR is now marked terminal and future scans (including forced dispatch) will skip it. To recover: delete this bot's terminal \`autofix-eval\` marker comment, then re-trigger if the failure looked transient." | |
| fi | |
| # Consecutive-failure circuit breaker, distinct from the round cap. | |
| # Reaching this step at all means this round did NOT push (the push | |
| # and no-op paths report from "Push and report"), so this round is a | |
| # failure. Count how many failures precede it WITHOUT a break: walk | |
| # the bot's prior eval markers in API order (oldest-first, pinned | |
| # by sort_by so a stray reorder cannot corrupt the streak) and | |
| # reset the streak at each push ("Addressed the latest review | |
| # feedback"), deliberate no-op ("no changes needed"), or pre-agent | |
| # infra-failure marker ("AutoFix could not start"). After the | |
| # full walk, CONSEC_FAIL holds failures since the last progress | |
| # point plus one for this round. If the unbroken | |
| # streak (this round included) reaches the cap, stop retrying even | |
| # under takeover: a PR that fails this many times running is stuck | |
| # on something a re-run at the same budget will not fix (observed on | |
| # #6723: 7 straight failures, 3 timeouts + 4 gate rejections). Only | |
| # overrides a would-be RETRY — a round already terminal for another | |
| # reason keeps its own headline. | |
| # Transient model errors (429/5xx) are exempt: the CAUSE_MAX logic | |
| # above deliberately gives them the full round budget because they | |
| # self-heal once the provider recovers. Letting the breaker override | |
| # that would mark every in-flight PR terminal at once during a | |
| # provider outage — the failures are not the PR's fault and DO | |
| # self-heal. Auth errors are NOT exempt (they never self-heal). | |
| # Pre-agent infra failures (skipped/cancelled/empty Prepare outcome) | |
| # are exempt for the same reason: a broken base build or a runner | |
| # crash is not the PR's fault, self-heals, and hits the whole scan | |
| # batch at once — the exact scenario the retry path above exists to | |
| # prevent. The round cap + sentinel-ts /retry recovery already | |
| # bounds a persistently broken base. A stale-base retry (the gate | |
| # rejected the fix but the PR was behind main, so the base was just | |
| # updated) is exempt for the same reason — it is not the PR's fault | |
| # and self-limits to one round (after the update the PR is current). | |
| if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]] && [[ "${PREPARE_OUTCOME}" == 'success' || "${PREPARE_OUTCOME}" == 'failure' ]] && [[ "${STALE_BASE_RETRY:-false}" != 'true' ]] && { [[ -z "${API_ERROR_DETAIL}" ]] || [[ "${API_ERROR_KIND}" == 'auth' ]]; }; then | |
| CONSEC_FAIL=1 | |
| if [[ -f "${WORKDIR}/ic.json" ]]; then | |
| COMMENTS_JSON="$(cat "${WORKDIR}/ic.json")" | |
| else | |
| # Truncated stream: jq -s fails and || true yields '' where | |
| # the old code kept partial JSON — PRIOR_HEADS empties and | |
| # duplicate-report suppression weakens for one round (a | |
| # redundant comment at worst, never a cap reset); a total gh | |
| # failure now yields '[]' instead of the old ''. | |
| COMMENTS_JSON="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate 2> /dev/null | jq -s 'add // []' || true)" | |
| fi | |
| PRIOR_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' | |
| [.[] | select((.user.login // "") == $ab) | |
| | select((.body // "") | contains("<!-- autofix-eval ")) | |
| | select( | |
| ((.body // "") | contains("win=" + $win + " -->")) | |
| or ($win == "none" and (((.body // "") | contains("win=")) | not)))] | |
| | sort_by(.created_at) | .[] | |
| | (.body | gsub("\r"; "") | split("\n")[0])' <<< "${COMMENTS_JSON}" 2> /dev/null || true)" | |
| while IFS= read -r H; do | |
| [[ -n "${H}" ]] || continue | |
| if [[ "${H}" == *"Addressed the latest review feedback"* || "${H}" == *"no changes needed"* || "${H}" == *"AutoFix could not start —"* || "${H}" == *"updated a stale base"* ]]; then | |
| CONSEC_FAIL=1 | |
| else | |
| CONSEC_FAIL=$(( CONSEC_FAIL + 1 )) | |
| fi | |
| done <<< "${PRIOR_HEADS}" | |
| if [[ "${CONSEC_FAIL}" -ge "${CONSECUTIVE_FAILURE_CAP}" ]]; then | |
| MARK_ROUND="${MAX_ROUNDS}" | |
| HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that failed to push anything (timeouts and/or gate rejections). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." | |
| fi | |
| # CUMULATIVE timeout breaker — the sibling of the consecutive | |
| # one above, for the failure shape it cannot see: timeouts | |
| # interleaved with pushed rounds. A push resets CONSEC_FAIL, | |
| # but it does not make the next timeout cheaper — each burns a | |
| # full agent budget with nothing to show (observed on #7929: | |
| # three timeouts with successes in between; #7846 twice). The | |
| # census reuses PRIOR_HEADS, so it is window-scoped exactly | |
| # like the consecutive one and a re-arm clears it. Only | |
| # overrides a would-be RETRY: a round already terminal keeps | |
| # its own headline (the consecutive breaker included). | |
| if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]]; then | |
| # Needle matches the emitted headline verbatim — first lines | |
| # can embed provider error text (API_ERROR_DETAIL puts up to | |
| # 200 bytes of it on the same line), so a loose phrase could | |
| # count a model error message as a timeout. | |
| TIMEOUT_N="$(grep -c 'AutoFix ran out of time before finishing' <<< "${PRIOR_HEADS}" || true)" | |
| if [[ -n "${AGENT_TIMEOUT:-}" ]]; then | |
| TIMEOUT_N=$(( TIMEOUT_N + 1 )) | |
| fi | |
| if [[ "${TIMEOUT_N}" -ge "${TIMEOUT_WINDOW_CAP}" ]]; then | |
| MARK_ROUND="${MAX_ROUNDS}" | |
| # The headline states what the census MEASURED — the | |
| # window's cumulative count — not "stopped after N | |
| # timeouts": the round that trips this can itself have | |
| # failed differently (a gate rejection landing on a window | |
| # that already carries the cap — the exact rollout state | |
| # of #7929/#7846). | |
| HEADLINE="🤖 AutoFix stopped: this counting window now contains ${TIMEOUT_N} time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). That is ${TIMEOUT_N} full agent runs that pushed nothing. A human should split or reduce the PR (or raise the agent time budget AND its step backstop together), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." | |
| fi | |
| fi | |
| fi | |
| { | |
| echo "${HEADLINE}" | |
| echo | |
| if [[ -n "${DETAIL_FILE}" ]]; then | |
| if [[ "${COMMITTED}" == "true" ]]; then | |
| # The agent committed (verify recorded committed=true before | |
| # any gate could fail), but every path that reaches this | |
| # handoff skipped "Push and report" — nothing landed on the | |
| # branch. Say so before the agent's address-summary.md, which | |
| # can read like a success and cite that now-discarded commit | |
| # SHA. Keyed on committed, NOT outcome=failed: the abort/no-op | |
| # paths (failure.md, dirty tree, unchanged branch, missing | |
| # summary) made no commit and keep the neutral framing below. | |
| echo "⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:" | |
| else | |
| echo "**What I found before stopping:**" | |
| fi | |
| # -c drops any partial multi-byte sequence a byte-level head -c may | |
| # have split, so the comment body stays valid UTF-8. iconv -c still | |
| # EXITS 1 when it discards a byte, which under this shell's | |
| # `set -eo pipefail` would abort the step and skip the marker + gh | |
| # pr comment below — the exact silent stall this block prevents — so | |
| # `|| true` keeps the (already-emitted) cleaned text and continues. | |
| head -c 1500 "${DETAIL_FILE}" | iconv -f utf-8 -t utf-8 -c | sed 's/<!--/<!\\-\\-/g' || true | |
| else | |
| echo "AutoFix failed before producing a verified commit (the run crashed or timed out before it could explain why)." | |
| fi | |
| # Carry the gate's own rejection reason. Without it the comment | |
| # shows only the agent's optimistic summary, so neither a human | |
| # nor the NEXT round can see why the change was refused - the | |
| # agent then re-reads the original review feedback and repeats | |
| # the same mistake. Delimited so `Prepare branch and feedback` | |
| # can lift it back out on the retry. | |
| if [[ -s "${WORKDIR}/gate-rejection.md" ]]; then | |
| echo | |
| echo '<!-- autofix-gate-rejection-start -->' | |
| echo "**Why it was not pushed:**" | |
| echo | |
| # Must stay >= reject_fix's tail -c 3000 + label + two four-backtick | |
| # fences (~3.1 KB), or the closing fence is silently truncated. | |
| head -c 3500 "${WORKDIR}/gate-rejection.md" | iconv -f utf-8 -t utf-8 -c | sed 's/<!--/<!\\-\\-/g' || true | |
| echo '<!-- autofix-gate-rejection-end -->' | |
| fi | |
| echo | |
| echo | |
| echo "Run log: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" | |
| echo | |
| echo "---" | |
| echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" | |
| echo | |
| echo "<!-- autofix-eval ts=${MARK_TS} acted=false round=${MARK_ROUND} win=${WINDOW:-none} -->" | |
| # A sentinel ts means the agent evaluated NOTHING (crash, API | |
| # error, gate crash) and the next scan must retry. Recording a | |
| # judged head here would make RED_HEAD == LIVE_HEAD, so the | |
| # retry scan sees N_RED_NOW=0 and goes idle despite the handoff | |
| # promising a retry. | |
| if [[ "${MARK_TS}" != '9999-12-31T23:59:59Z' ]]; then | |
| echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->" | |
| fi | |
| } > "${WORKDIR}/report.md" | |
| gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}" | |
| fi | |
| # Flip the status comment out of "working" so a finished round never | |
| # leaves a live-looking line behind. PATCH-only on purpose: a round that | |
| # never posted a status (stale duplicate, dry run) must not gain one here. | |
| # The verdict stays in the round report this job already posts; this only | |
| # records that the round ended, and keeps the run link reachable. | |
| # Gated on 'stale' for the same reason the announcement is: the per-PR | |
| # concurrency group serialises duplicate address jobs, so the discarded | |
| # one runs AFTER the real round already finalised. Ungated, it would | |
| # overwrite that round's "finished" with its own "ended without | |
| # publishing" and report a successful round as a failed one. An empty | |
| # 'stale' (prepare itself crashed) still finalises — that IS this job's | |
| # round, and it is exactly the case that must not stay "working". | |
| - name: 'Finalize autofix status comment' | |
| if: |- | |
| ${{ always() && steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }} | |
| env: | |
| GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' | |
| EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' | |
| OUTCOME: '${{ steps.final_verify.outputs.outcome }}' | |
| RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' | |
| # The id the announcement wrote. Empty means this round never | |
| # announced (its step was skipped, or the post itself failed) — then | |
| # no comment claims this round is working, so there is nothing to | |
| # flip and no reason to scan for one. A previous round's comment is | |
| # already terminal, and the next round's announcement re-PATCHes it. | |
| STATUS_ID: '${{ steps.post_status.outputs.comment_id }}' | |
| run: |- | |
| set -uo pipefail | |
| MARKER='<!-- autofix-status -->' | |
| if [[ -z "${STATUS_ID}" ]]; then | |
| echo "This round posted no status comment on PR #${PR}; nothing to finalize." | |
| exit 0 | |
| fi | |
| ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" | |
| # ROUND counts rounds already DONE; every other message numbers the | |
| # round being performed (the report posts ROUND + 1). Match it, or | |
| # the same round carries two different numbers in one thread. | |
| if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then | |
| ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" | |
| fi | |
| # 'fixed'/'noop' are the two outcomes that published a round report; | |
| # anything else means the round stopped before publishing one. | |
| if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' ]]; then | |
| EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")" | |
| ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")" | |
| else | |
| EN="$(printf '⚠️ **AutoFix round %s ended without publishing a report** — [view run](%s).' "${ROUND_DISPLAY}" "${RUN_URL}")" | |
| ZH="$(printf '⚠️ **AutoFix 第 %s 轮结束但未发布报告** —— [查看运行](%s)。' "${ROUND_DISPLAY}" "${RUN_URL}")" | |
| fi | |
| BODY="$(printf '%s\n\n%s\n\n<details>\n<summary>中文说明</summary>\n\n%s\n\n</details>' \ | |
| "${MARKER}" "${EN}" "${ZH}")" | |
| gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \ | |
| -f body="${BODY}" > /dev/null || | |
| echo "::warning::Failed to finalize the autofix status comment on PR #${PR}; continuing." | |
| # Nothing else removes the per-target WORKDIR; PR numbers only | |
| # increase, so on the persistent pool every addressed PR would leave | |
| # its transcripts and decision files behind forever. Last step, after | |
| # every reader including the artifact upload. always() covers | |
| # cancellation too; a dir abandoned by a hard runner kill is reclaimed | |
| # by the next same-PR run's reset, or by the age sweep in Reset | |
| # autofix workspace if that PR is never addressed again. | |
| - name: 'Clean up autofix workdir' | |
| if: 'always()' | |
| run: 'rm -rf "${WORKDIR}"' |