feat(autofix): direct takeover of maintainer-fork PRs (#7213)

* feat(autofix): direct takeover of maintainer-fork PRs

Maintainer-approved v2: many maintainers work from personal forks, and
adoption-snapshotting breaks their local workflow. A fork PR is now
directly manageable when three live conditions hold — the takeover
label, 'Allow edits from maintainers' (org-owned forks cannot enable
it; adoption remains their path), and a fork author who holds write+
RIGHT NOW (the same live-privilege rule as the comment command, so an
ex-member's fork can never summon secret-bearing runs).

Plumbing:
- Scan: fork takeover candidates are admitted per candidate (allow-
  edits + no-skip filtered in jq; the author's live write+ gate is one
  permission call each — a rare set); every matrix target now carries
  its head repo.
- Address: prepare fetches the fork branch (origin has no copy) and
  checks out FETCH_HEAD with hooks already severed; the eligibility
  gate re-verifies takeover + allow-edits + author write+ live; the
  report step pushes back to the fork via the allow-edits grant.
- Triggers: fork pull_request label events carry NO secrets, so the
  route notes them and the next scheduled scan engages (≤10m); the
  comment command now toggles fork PRs too (write+ senders only — fork
  authors stay silently dropped) and refuses only when allow-edits is
  missing, with the actionable ask. The scan posts a first-pickup
  engage ack (identity-verified, deduped on any existing ack, ic.json
  re-fetched so the same scan counts under the fresh window key) —
  closing the fork/manual-label ack gap and anchoring the round
  window.

Behavioral coverage: fork-candidate admission jq (allow-edits, skip,
in-repo exclusion, tsv rows), eligibility across
fork+takeover+allow-edits+write / no-allow-edits / read-author, the
toggle's fork split (refusal vs managed), plus plumbing pins (fork
fetch/push forms, head_repo threading, first-pickup ack dedup).
60/60 + 12/12.

* fix(autofix): strip stray patch-artifact quotes after two fi keywords

Two inserted blocks ended 'fi"' — the quotes balanced against each
other inside the same script, so bash -n stayed green while runtime
would have lexed 'fi' as a command word and swallowed the span between
them (the fork head-repo resolution tail and the engage-ack block)
into one string. Removed both, and pinned the artifact class in the
suite: a lone fi/done/esac followed by a quote now fails the tests.
61/61 + 12/12.

* fix(autofix): author-filtered, re-armable first-pickup engage ack

Reverse-audit findings on the scan-side ack:

- Dedup was a raw grep over ic.json — a forged human comment carrying
  the engaged marker would have suppressed the real ack (and with it
  the window anchor). Dedup now selects bot-authored engaged acks via
  jq, same author rule as the window key itself.
- Fork PRs get NO ack job (label events carry no secrets), so the
  documented re-arm gesture — remove and re-add the label to reset the
  round window — silently kept the old window: any historical ack
  blocked a fresh one. When a bot ack already exists, the scan now
  compares it against the takeover label's latest application time
  (issue events, fetched only in that rare case); a newer application
  posts a fresh ack, resetting window and cap as documented.

Coverage: verbatim jq replays for both selections (forged-marker and
released-marker exclusion, label-name filter, sort|last) plus the
lexicographic re-arm gate pin. 61/61 + 12/12.

* fix(autofix): review round 1 — ack ordering, dry-run, ghost-engage gate

Addresses the maintainer review on #7213 (all findings confirmed):

- Critical: the first-pickup ack read ic.json BEFORE the per-PR fetch —
  the first takeover candidate killed the whole scan step (missing file
  under -eo pipefail; every in-repo label-forced scan regressed), and
  later candidates dedup'd against the PREVIOUS PR's comments (bot PR
  ahead → fresh ack every 10min → window reset → cap never binds). The
  block now sits directly AFTER the fetch; its post-ack re-fetch keeps
  the downstream MARKERS/window-key reads fresh. A contract pin asserts
  the fetch precedes the first ack-timestamp read.
- Medium: the ack now honors DRY_RUN (log only, window key untouched).
- Medium: the command refused forks only for missing allow-edits — a
  below-write fork author was a silent ghost engagement (label sticks,
  no ack, nothing ever manages it). The command now mirrors the scan's
  author write+ gate with an actionable bilingual refusal. Found while
  fixing it: PR_INFO never fetched maintainerCanModify (or author), so
  EVERY fork toggle refused regardless of allow-edits — the test stub
  carried the field and masked the gap. Both engage-side fork gates are
  now also scoped to 'add': release is never blocked.
- Low: the two new paginated jq reads are slurped (add-merged) so >100
  comments/events cannot scramble the timestamp comparisons; replays
  now feed two concatenated page-documents. Fork fetch pins
  refs/heads/ (tag shadowing); HEAD_REPO_FULL guards each component
  (deleted fork = owner XOR name empty); fork-rotation caveat
  documented; forced-path refusal mentions the scheduled fork path.
- Security caveat adopted: prepare proves fork push access with a
  --dry-run push right after checkout (allow-edits rides the
  classic-PAT grant only) and discards gracefully instead of 403ing
  after a full agent round.

61/61 + 12/12; YAML parses; every run block passes bash -n.

* fix(autofix): fork targets keep base/branch invariants + 3 hardening follow-ups

Blocking (yiliang114): the last fork elif ends the eligibility ladder
for every eligible fork, so the LIVE_BASE/LIVE_BRANCH re-checks were
unreachable for exactly the PR class the loop fetches and pushes — a
labeled fork retargeted off main (or head-renamed) between scan and
address would have had conflicts resolved against the wrong base. The
base/branch invariants now sit ABOVE the fork chain (comment explains
why the order is load-bearing), with replay cases pinning a
retargeted and a renamed fork to the discard path.

Follow-ups from the same review, all adopted:
- PR_LIVE re-reads headRepositoryOwner/headRepository; a fork renamed
  or transferred since the scan discards at the live re-check (moved
  or unresolved, fail-closed) instead of fetching and token-pushing a
  stale path. The replay's fork fixture now carries its head repo and
  the harness provides the matrix HEAD_REPO to compare against.
- The fork fetch failure (force-push/rename race) discards through the
  standard no-action path instead of a red run.
- The first-pickup engage ack defers to the in-repo label event's
  DEDICATED ack job within a 3-minute grace after the label lands, so
  a concurrent ack job is never double-posted (which would shift the
  round-window anchor); a failed ack job is still healed by the next
  scan, and forks (no ack job) keep immediate pickup. Events are read
  once, before the branch split.

Both hooks-order regex windows widened to span the new fork-arm guards
(the assertions are about order; one hooksPath site genuinely covers
both checkout arms). 61/61 + 12/12.

* test: raise timeout ceiling for I/O-bound tests flaky under CI contention

The self-hosted CI runners are heavily oversubscribed (core runs
maxThreads: 16), and a recurring class of tests blows vitest's 5s
default timeout purely under that contention — not from any logic
fault. Observed repeatedly across unrelated PRs (#7213, #7219, and
noted in prior sessions):

- packages/core/src/utils/shell-ast-parser-lazy.test.ts — fully
  mocked, but the dynamic import + async coordination exceeds 5s when
  16 threads contend.
- packages/cli/src/serve/workspace-registration-store.test.ts —
  tempdir round-trip.
- packages/core/src/extension/github.test.ts > extractFile — its
  waitForFileData helper polled a FIXED 1_000 setImmediate turns, which
  elapse in <100ms while the tar extraction I/O is still catching up,
  throwing 'Timed out waiting for extracted data'.

Fixes:
- testTimeout: 15000 in the core and cli vitest configs — 3x the
  default. Assertions still fail instantly; only the timeout ceiling
  grows, so this masks no logic bug (a real hang still fails, just
  later, and the job timeout still bounds it).
- waitForFileData now polls a real ~10s wall-clock budget
  (2_000 x 5ms) instead of a fixed iteration count, so a slow
  extraction is awaited rather than raced. Stays under the 15s ceiling.

These are the deterministic root-cause fixes for the flake class the
autofix loop and CI Failure Patrol were papering over with reruns.

---------

Co-authored-by: wenshao <wenshao@example.com>
This commit is contained in:
Shaojin Wen 2026-07-19 20:49:55 +08:00 committed by GitHub
parent 5c7a5a84e0
commit 68c9032cd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 491 additions and 30 deletions

View file

@ -12,7 +12,12 @@ name: 'Qwen Autofix'
# • 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);
# 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
@ -382,9 +387,13 @@ jobs:
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 — we cannot
# even post a rejection comment. Log and stop.
echo "🧭 takeover ignored: PR is a fork (${PR_HEAD_REPO} != ${REPO})"
# 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
@ -1216,7 +1225,7 @@ jobs:
# 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 2> /dev/null)"; then
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
@ -1235,11 +1244,36 @@ jobs:
echo "🧭 takeover command refused: ${SKIP_LABEL} present on #${PR}"
exit 0
fi
if [[ "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" ]]; then
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover is not available for fork-based PRs. A maintainer can adopt this 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 暂不支持托管。维护者可改用领养方式:将 head 快照为本仓库分支并另开 PRcommit 署名保留),再对新 PR 执行接管。\n\n</details>\n\n<!-- takeover-ack fork-refused -->')"
echo "🧭 takeover command refused: PR #${PR} is a fork"
# 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 快照为本仓库分支并另开 PRcommit 署名保留),再对新 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
@ -1373,7 +1407,7 @@ jobs:
and (.isCrossRepository == false)
and ((.baseRefName // "") == "main"))' <<< "${META}")"
if [[ "${OK}" != "true" ]]; then
echo "❌ #${FORCED_PR} is not an open in-repo main-targeting PR owned by ${AUTOFIX_BOT} or labeled ${TAKEOVER_LABEL} (or it carries ${SKIP_LABEL})"
echo "❌ #${FORCED_PR} is not an open in-repo main-targeting PR owned by ${AUTOFIX_BOT} or labeled ${TAKEOVER_LABEL} (or it carries ${SKIP_LABEL}); fork takeover PRs are engaged by the scheduled scan, not manual dispatch"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 0
@ -1385,7 +1419,7 @@ jobs:
--limit 100 --json number,headRefName,isCrossRepository,labels > "${WORKDIR}/bot-prs.json"
gh pr list --repo "${REPO}" --state open --label "${TAKEOVER_LABEL}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels > "${WORKDIR}/takeover-prs.json"
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${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
@ -1405,6 +1439,31 @@ jobs:
| .[]
| .number' \
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json")"
# FORK takeover 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). Rare set — one permission call each.
# 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
FPERM="$(gh api "repos/${REPO}/collaborators/${FAUTHOR}/permission" --jq '.permission // ""' 2> /dev/null || echo '')"
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"
;;
esac
done < <(jq -r --arg skip "${SKIP_LABEL}" '
.[] | select(.isCrossRepository == true)
| select(.maintainerCanModify == true)
| select([.labels[]?.name] | index($skip) | not)
| [(.number | tostring), (.author.login // "")] | @tsv' \
"${WORKDIR}/takeover-prs.json")
fi
# Pending-check staleness bound (invariant across candidate PRs, computed
@ -1467,7 +1526,19 @@ jobs:
# (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,statusCheckRollup,createdAt,labels 2> /dev/null || echo '{}')"
--json headRefName,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"
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
@ -1520,6 +1591,70 @@ jobs:
CREATED_WM="$(jq -r '.createdAt // ""' <<< "${PR_META}")"
gh api "repos/${REPO}/issues/${PR}/comments" --paginate > "${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 > "${WORKDIR}/pr-events.json" 2> /dev/null \
|| 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'
# In-repo label events have a DEDICATED ack job — the scan is
# only its healer. Within a short grace after the label lands,
# defer: a concurrent ack job must not be double-posted (that
# shifts the window anchor). A failed ack job is healed by the
# next scan, which is past the grace. Forks have no ack job,
# so no grace applies there.
if [[ "$(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
gh api "repos/${REPO}/issues/${PR}/comments" --paginate > "${WORKDIR}/ic.json" \
|| echo "::warning::ic re-fetch after engage ack failed for #${PR}"
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.
@ -1685,8 +1820,8 @@ jobs:
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}" \
'. + [{pr: $pr, branch: $branch, issue: $issue, round: $round, watermark: $wm, max_rounds: $mr, win: $win}]' \
--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
@ -1747,6 +1882,10 @@ jobs:
# 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
@ -1876,10 +2015,11 @@ jobs:
# 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 2> /dev/null || echo '{}')"
--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
@ -1893,9 +2033,32 @@ jobs:
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_XREPO}" != "false" ]]; then INELIGIBLE='fork head'
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" ]]; then INELIGIBLE='fork head without takeover'
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"
@ -1916,7 +2079,37 @@ jobs:
# agent step (no PAT, sandboxed tools) re-points hooksPath at
# .husky itself so ITS commits still get checked.
git config core.hooksPath /dev/null
git checkout -B "${BRANCH}" "origin/${BRANCH}"
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
# 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 push --no-verify --dry-run "https://x-access-token:${GITHUB_TOKEN}@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
# Does the branch conflict with base? merge-tree computes the merge
# without touching the tree; exit 1 means conflicts. UNKNOWN/errors are
@ -2346,7 +2539,14 @@ jobs:
# 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
git push --no-verify origin "${BRANCH}"
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).
git push --no-verify "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}"
else
git push --no-verify origin "${BRANCH}"
fi
{
echo "🤖 Addressed the latest review feedback (round ${NEXT_ROUND}/${MAX_ROUNDS}). What changed, and what I pushed back on:"
echo

View file

@ -200,6 +200,13 @@ describe('qwen-autofix workflow', () => {
expect(workflow).toContain('.[0:10] | map(. + {autofixTier: 1})');
});
it('carries no patch-artifact stray quotes on shell keywords', () => {
// A trailing '"' after a lone fi/done/esac balances against the NEXT
// quote in the script, so bash -n stays green while runtime semantics
// are scrambled — pin the artifact class directly.
expect(workflow).not.toMatch(/^\s*(fi|done|esac)"\s*$/m);
});
it('runs scheduled autofix as a 10-minute multi-target fan-out worker', () => {
expect(workflow).toContain("cron: '*/10 * * * *'");
expect(workflow).not.toContain("cron: '0 0,12 * * *'");
@ -569,7 +576,7 @@ describe('qwen-autofix workflow', () => {
/(PR_LIVE="\$\(gh pr view[\s\S]*?exit 0\n {10}fi)/,
)?.[1];
expect(recheck).toBeTruthy();
const runRecheck = (prJson) => {
const runRecheck = (prJson, authorPerm = 'write') => {
const dir = mkdtempSync(join(tmpdir(), 'autofix-elig-'));
try {
const gh = join(dir, 'gh');
@ -577,7 +584,7 @@ describe('qwen-autofix workflow', () => {
gh,
prJson === null
? '#!/bin/bash\nexit 1\n'
: `#!/bin/bash\nprintf '%s' '${JSON.stringify(prJson)}'\n`,
: `#!/bin/bash\nif [[ "$*" == *"/collaborators/"* ]]; then printf '%s' '${authorPerm}'; else printf '%s' '${JSON.stringify(prJson)}'; fi\n`,
);
chmodSync(gh, 0o755);
const out = join(dir, 'out.txt');
@ -592,6 +599,7 @@ describe('qwen-autofix workflow', () => {
PR: '7163',
REPO: 'QwenLM/qwen-code',
BRANCH: 'ci/some-branch',
HEAD_REPO: 'maint-fork/qwen-code',
WATERMARK: '2026-07-18T08:00:00Z',
ROUND: '2',
AUTOFIX_BOT: 'qwen-code-dev-bot',
@ -647,9 +655,40 @@ describe('qwen-autofix workflow', () => {
expect(
runRecheck(pr({ labels: [{ name: 'autofix/skip' }] })).out,
).toContain('stale=true');
// Fork head, renamed branch, and a FAILED fetch (unknown ≠ eligible)
// all discard.
// Fork heads: manageable ONLY with takeover + allow-edits + a fork
// author who holds write+ LIVE; anything less discards.
expect(runRecheck(pr({ isCrossRepository: true })).passed).toBe(false);
const forkPr = pr({
isCrossRepository: true,
maintainerCanModify: true,
author: { login: 'maint-fork' },
labels: [{ name: 'autofix/takeover' }],
headRepositoryOwner: { login: 'maint-fork' },
headRepository: { name: 'qwen-code' },
});
expect(runRecheck(forkPr).passed).toBe(true);
expect(runRecheck({ ...forkPr, maintainerCanModify: false }).passed).toBe(
false,
);
expect(runRecheck(forkPr, 'read').passed).toBe(false);
// The base/branch invariants must remain REACHABLE for eligible forks:
// the fork elif chain ends the ladder, so a retargeted or head-renamed
// fork previously sailed through to a wrong-base push.
expect(runRecheck({ ...forkPr, baseRefName: 'develop' }).passed).toBe(
false,
);
expect(runRecheck({ ...forkPr, headRefName: 'renamed' }).passed).toBe(
false,
);
// A fork renamed/transferred since the scan must not be fetched or
// pushed at the stale path — moved or unresolved discards.
expect(
runRecheck({ ...forkPr, headRepositoryOwner: { login: 'somewhere' } })
.passed,
).toBe(false);
expect(runRecheck({ ...forkPr, headRepository: { name: '' } }).passed).toBe(
false,
);
expect(runRecheck(pr({ headRefName: 'renamed' })).passed).toBe(false);
// Retargeted off main while queued → discard (previously only pinned).
expect(runRecheck(pr({ baseRefName: 'develop' })).passed).toBe(false);
@ -861,7 +900,9 @@ describe('qwen-autofix workflow', () => {
// Decide gates: takeover only for open in-repo main-targeting PRs; fork
// label events carry no secrets, so they are logged and dropped.
expect(routeStep).toContain('→ review phase (takeover)');
expect(routeStep).toContain('takeover ignored: PR is a fork');
// Fork label events (no secrets) note the takeover for the next
// scheduled scan instead of dropping it.
expect(routeStep).toContain('fork takeover noted for #${PR_NUMBER_EVENT}');
expect(routeStep).toContain('is not open');
expect(routeStep).toContain('→ released');
// Every toggle produces a visible bilingual ack via the PAT-verified bot
@ -876,12 +917,13 @@ describe('qwen-autofix workflow', () => {
// EVERY body proves it individually (a global count alone could balance
// one lost Chinese section against a duplicate elsewhere): engage,
// honest bot-PR release, skip-labeled bot-PR release, human-PR
// release, re-arm, fork refusal, two skip-blocked refusals, and the cap
// pause.
// release, re-arm, fork allow-edits refusal, two skip-blocked refusals,
// the cap pause, and the scan-side first-pickup engage ack (fork label
// events carry no secrets, so the scan anchors the window itself).
const ackBodies = workflow.match(
/printf '[^']*takeover-(?:ack|cap)[^']*'/g,
);
expect(ackBodies).toHaveLength(9);
expect(ackBodies).toHaveLength(11);
for (const body of ackBodies) {
expect(body).toContain('<summary>中文说明</summary>');
}
@ -905,7 +947,12 @@ describe('qwen-autofix workflow', () => {
// and the command job — which DOES have secrets — refuses forks up
// front with an explanation instead of toggling the label.
expect(routeStep).toContain('takeover release ignored: PR is a fork');
expect(workflow).toContain('takeover command refused: PR #${PR} is a fork');
// Fork PRs with allow-edits ARE manageable now; only a fork WITHOUT
// maintainer-edit access refuses (with the actionable ask).
expect(workflow).toContain(
'takeover command refused: fork PR #${PR} without maintainer-edit access',
);
expect(workflow).toContain('Allow edits from maintainers');
expect(workflow).toContain('<!-- takeover-ack fork-refused -->');
// Convention: every write verifies the PAT identity first — including
// the scan's cap notice (a foreign login would defeat the dedup and
@ -988,6 +1035,187 @@ describe('qwen-autofix workflow', () => {
// Rotation: offset 1 starts one past the newest, wrapping — so the
// oldest tail is reached within pool/budget scans instead of never.
expect(pick([pr(1), pr(2)], [], 1)).toEqual(['1', '2']);
// Fork takeover candidates are admitted separately: allow-edits and no
// skip label filter in jq; the author's live write+ gate runs in bash.
const forkSel = reviewScanJob
.match(
/done < <\(jq -r --arg skip "\$\{SKIP_LABEL\}" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/takeover-prs\.json"\)/,
)?.[1]
?.replace(/\n {14}/g, '\n');
expect(forkSel).toBeTruthy();
const forkRows = execFileSync(
'jq',
['-r', '--arg', 'skip', 'autofix/skip', forkSel],
{
encoding: 'utf8',
input: JSON.stringify([
{
number: 9,
isCrossRepository: true,
maintainerCanModify: true,
labels: [{ name: 'autofix/takeover' }],
author: { login: 'maint-a' },
},
{
number: 8,
isCrossRepository: true,
maintainerCanModify: false,
labels: [{ name: 'autofix/takeover' }],
author: { login: 'maint-b' },
},
{
number: 7,
isCrossRepository: true,
maintainerCanModify: true,
labels: [{ name: 'autofix/takeover' }, { name: 'autofix/skip' }],
author: { login: 'maint-c' },
},
{
number: 6,
isCrossRepository: false,
maintainerCanModify: true,
labels: [{ name: 'autofix/takeover' }],
author: { login: 'maint-d' },
},
]),
},
)
.trim()
.split('\n');
expect(forkRows).toEqual(['9\tmaint-a']);
expect(reviewScanJob).toContain('fork takeover candidate #${FPR} admitted');
// Fork plumbing: the target carries its head repo; prepare fetches the
// fork branch (origin has no copy) and the report pushes back via
// allow-edits.
expect(workflow).toContain("HEAD_REPO: '${{ matrix.target.head_repo }}'");
expect(reviewScanJob).toContain('head_repo: $hr');
expect(workflow).toContain(
'git fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"',
);
expect(workflow).toContain(
'git push --no-verify "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}"',
);
// The allow-edits grant rides the classic-PAT path only — prepare must
// prove push access BEFORE an agent round is spent, discarding
// gracefully instead of 403ing at the report step.
expect(workflow).toContain(
'git push --no-verify --dry-run "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}"',
);
expect(workflow).toContain('fork push preflight failed');
// First-pickup engage ack anchors the window when the label path could
// not (fork events carry no secrets), author-filtered-deduped,
// identity-verified, with ic.json re-fetched so the same scan counts
// under the fresh key.
expect(reviewScanJob).toContain('takeover-ack engaged');
expect(reviewScanJob).toContain('ic re-fetch after engage ack failed');
// Ack dedup is author-filtered (a forged human marker must not suppress
// the real ack) and re-armable: a takeover-label application newer than
// the latest bot ack posts a fresh ack, resetting the round window.
const ackTsProgram = reviewScanJob
.match(
/LAST_ENGAGE_ACK_TS="\$\(jq -rs --arg ab "\$\{AUTOFIX_BOT\}" '([\s\S]*?)' "\$\{WORKDIR\}\/ic\.json"\)"/,
)?.[1]
?.replace(/\n {16}/g, '\n');
expect(ackTsProgram).toBeTruthy();
// Two concatenated page-documents, the true latest in page 2 — proves
// the slurp handles gh api --paginate output past 100 comments.
const ackTs = execFileSync(
'jq',
['-rs', '--arg', 'ab', 'bot', ackTsProgram],
{
encoding: 'utf8',
input:
JSON.stringify([
{
user: { login: 'bot' },
body: 'x <!-- takeover-ack engaged -->',
created_at: '2026-07-01T00:00:00Z',
},
{
user: { login: 'mallory' },
body: 'fake <!-- takeover-ack engaged -->',
created_at: '2026-07-05T00:00:00Z',
},
]) +
JSON.stringify([
{
user: { login: 'bot' },
body: 'y <!-- takeover-ack engaged -->',
created_at: '2026-07-03T00:00:00Z',
},
{
user: { login: 'bot' },
body: 'released <!-- takeover-ack released -->',
created_at: '2026-07-04T00:00:00Z',
},
]),
},
).trim();
expect(ackTs).toBe('2026-07-03T00:00:00Z');
const labeledTsProgram = reviewScanJob
.match(
/LAST_LABELED_TS="\$\(jq -rs --arg lb "\$\{TAKEOVER_LABEL\}" '([\s\S]*?)' "\$\{WORKDIR\}\/pr-events\.json"\)"/,
)?.[1]
?.replace(/\n {16}/g, '\n');
expect(labeledTsProgram).toBeTruthy();
const labeledTs = execFileSync(
'jq',
['-rs', '--arg', 'lb', 'autofix/takeover', labeledTsProgram],
{
encoding: 'utf8',
input:
JSON.stringify([
{
event: 'labeled',
label: { name: 'autofix/takeover' },
created_at: '2026-07-02T00:00:00Z',
},
{
event: 'labeled',
label: { name: 'other' },
created_at: '2026-07-09T00:00:00Z',
},
]) +
JSON.stringify([
{
event: 'unlabeled',
label: { name: 'autofix/takeover' },
created_at: '2026-07-08T00:00:00Z',
},
{
event: 'labeled',
label: { name: 'autofix/takeover' },
created_at: '2026-07-06T00:00:00Z',
},
]),
},
).trim();
expect(labeledTs).toBe('2026-07-06T00:00:00Z');
expect(reviewScanJob).toContain(
'"${LAST_LABELED_TS}" > "${LAST_ENGAGE_ACK_TS}"',
);
// The dedup must read the CURRENT candidate's comments: pin the per-PR
// ic.json fetch BEFORE the first ack-timestamp read (reading a previous
// candidate's file mis-dedups; a missing file kills the scan step under
// -eo pipefail). Same textual-order technique as the hooks-severed pins.
const icFetchAt = reviewScanJob.indexOf(
'gh api "repos/${REPO}/issues/${PR}/comments" --paginate > "${WORKDIR}/ic.json"',
);
const ackReadAt = reviewScanJob.indexOf('LAST_ENGAGE_ACK_TS=');
expect(icFetchAt).toBeGreaterThan(-1);
expect(ackReadAt).toBeGreaterThan(icFetchAt);
// A dry-run scan must neither comment nor advance the real window key.
expect(reviewScanJob).toContain(
'DRY-RUN: would post engage ack on #${PR} (window key untouched)',
);
// In-repo first-pickup defers to the label event's DEDICATED ack job
// within a short grace, so a concurrent ack job is never double-posted.
expect(reviewScanJob).toContain('engage ack deferred for #${PR}');
// A fork fetch failure (force-push/rename race) discards gracefully
// instead of a red run, and a fork moved since the scan is discarded at
// the live re-check rather than fetched/pushed at the stale path.
expect(workflow).toContain('fork fetch failed for ${HEAD_REPO}');
expect(workflow).toContain('fork head repository moved or unresolved');
// The producers must actually REQUEST labels — the jq consumers above
// stay green on handcrafted fixtures even if a future edit drops the
// field and skip/takeover filtering silently dies in production.
@ -1128,6 +1356,8 @@ describe('qwen-autofix workflow', () => {
cmd,
labels = [],
fork = false,
canModify = true,
authorPerm = 'write',
state = 'OPEN',
base = 'main',
}) => {
@ -1135,6 +1365,8 @@ describe('qwen-autofix workflow', () => {
try {
const prJson = JSON.stringify({
isCrossRepository: fork,
maintainerCanModify: canModify,
author: { login: 'fork-owner' },
state,
baseRefName: base,
labels: labels.map((name) => ({ name })),
@ -1143,7 +1375,8 @@ describe('qwen-autofix workflow', () => {
join(dir, 'gh'),
[
'#!/bin/bash',
`if [[ "$1" == "pr" && "$2" == "view" ]]; then printf '%s' '${prJson}';`,
`if [[ "$1" == "api" && "$2" == */collaborators/*/permission ]]; then printf '%s' '${authorPerm}';`,
`elif [[ "$1" == "pr" && "$2" == "view" ]]; then printf '%s' '${prJson}';`,
`elif [[ "$1" == "pr" && "$2" == "edit" ]]; then echo "EDIT $*" >> '${join(dir, 'writes.log')}';`,
`elif [[ "$1" == "pr" && "$2" == "comment" ]]; then echo "COMMENT $4" >> '${join(dir, 'writes.log')}'; cat > /dev/null <<< "$6";`,
'fi',
@ -1202,10 +1435,29 @@ describe('qwen-autofix workflow', () => {
const skipBlocked = runToggle({ cmd: 'add', labels: ['autofix/skip'] });
expect(skipBlocked.writes).toContain('COMMENT');
expect(skipBlocked.writes).not.toContain('EDIT');
// fork PRs are refused with an explanation, never toggled.
const forkRefused = runToggle({ cmd: 'add', fork: true });
// Fork WITHOUT allow-edits refuses with the actionable ask, never
// toggling; fork WITH allow-edits is fully manageable and toggles.
const forkRefused = runToggle({ cmd: 'add', fork: true, canModify: false });
expect(forkRefused.writes).toContain('COMMENT');
expect(forkRefused.writes).not.toContain('EDIT');
const forkManaged = runToggle({ cmd: 'add', fork: true });
expect(forkManaged.writes).toContain('--add-label');
expect(forkManaged.writes).not.toContain('COMMENT');
// A below-write fork author would be a ghost engagement (label sticks,
// nothing ever manages it) — the command refuses with the adoption ask.
const forkGhost = runToggle({ cmd: 'add', fork: true, authorPerm: 'read' });
expect(forkGhost.writes).toContain('COMMENT');
expect(forkGhost.writes).not.toContain('EDIT');
expect(forkGhost.log).toContain('below write');
// Release is NEVER blocked by engage-side fork requirements: stop on an
// allow-edits-revoked fork still removes the label.
const forkStop = runToggle({
cmd: 'remove',
fork: true,
canModify: false,
labels: ['autofix/takeover'],
});
expect(forkStop.writes).toContain('--remove-label');
});
it('behaviorally resets round counting at the latest takeover engage ack', () => {
@ -2301,8 +2553,17 @@ describe('qwen-autofix workflow', () => {
// …both pushes AND the prepare checkout (post-checkout hooks fire with
// the PAT in env there); the agent step — no PAT, sandboxed tools —
// re-points .husky itself so its commits still get checked.
// Hooks are severed BEFORE either checkout form (origin branch or the
// fork-remote FETCH_HEAD path used by maintainer-fork takeover). The
// fork arm carries the fetch-failure discard before its checkout and
// the origin form sits in the else-branch after the push preflight,
// hence the wider windows — the assertions are about order, and one
// hooksPath site genuinely covers both arms of the if.
expect(workflow).toMatch(
/git config core\.hooksPath \/dev\/null\n\s+git checkout -B "\$\{BRANCH\}"/,
/git config core\.hooksPath \/dev\/null\n[\s\S]{0,900}git checkout -B "\$\{BRANCH\}" FETCH_HEAD/,
);
expect(workflow).toMatch(
/git config core\.hooksPath \/dev\/null\n[\s\S]{0,2200}git checkout -B "\$\{BRANCH\}" "origin\/\$\{BRANCH\}"/,
);
expect(workflow).toMatch(
/git config core\.hooksPath \.husky\n[\s\S]{0,200}node "\$\{RUNNER_TEMP\}\/autofix-skill\/scripts\/run-agent\.mjs"/,