mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-11 09:46:05 +00:00
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:
parent
5c7a5a84e0
commit
68c9032cd5
2 changed files with 491 additions and 30 deletions
234
.github/workflows/qwen-autofix.yml
vendored
234
.github/workflows/qwen-autofix.yml
vendored
|
|
@ -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 快照为本仓库分支并另开 PR(commit 署名保留),再对新 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 快照为本仓库分支并另开 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"/,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue