Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ name: 'Qwen Autofix'
# unlabeled releases it. autofix/skip opts any PR
# out everywhere and wins over takeover. Labels
# need GitHub triage+, so the permission gate is
# GitHub's own.
# GitHub's own. The bot's OWN fork PRs (author ==
# the autofix bot, e.g. its codex flow) are auto-
# managed WITHOUT a label when allow-edits is on —
# they are the bot's own generated work, trust-
# equal to an in-repo bot PR; autofix/skip still
# opts them out.
# • issue_comment → '@qwen-code /takeover' (apply the label) and
# '@qwen-code /takeover stop' (remove it) — sugar
# for people without label access: the PR author,
Expand Down Expand Up @@ -1423,7 +1428,7 @@ jobs:
else
gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels > "${WORKDIR}/bot-prs.json"
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${WORKDIR}/bot-prs.json"
gh pr list --repo "${REPO}" --state open --label "${TAKEOVER_LABEL}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${WORKDIR}/takeover-prs.json"
Expand All @@ -1446,10 +1451,15 @@ 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.
# FORK PRs are admitted per candidate: the author must hold write+
# RIGHT NOW (the same live-privilege rule as the comment command)
# and the PR must allow maintainer edits (or the bot cannot push).
# Two sources, unioned: takeover-LABELED forks (any eligible author,
# explicit opt-in) AND the bot's OWN forks (bot-prs.json is
# --author AUTOFIX_BOT) — a fork the bot itself opened is its own
# generated work, trust-equal to an in-repo bot PR, so it needs no
# label (autofix/skip still opts it out). Rare set — one permission
# call each; the write+ check below still gates every candidate.
# Appended after the rotated in-repo list: forks sit outside the
# anti-starvation rotation, which only bites once in-repo
# candidates alone exhaust the inspection budget.
Expand All @@ -1465,12 +1475,13 @@ jobs:
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")
done < <(jq -rs --arg skip "${SKIP_LABEL}" '
add | unique_by(.number)
| .[] | select(.isCrossRepository == true)
| select(.maintainerCanModify == true)
| select([.labels[]?.name] | index($skip) | not)
| [(.number | tostring), (.author.login // "")] | @tsv' \
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json")
fi

# Pending-check staleness bound (invariant across candidate PRs, computed
Expand Down Expand Up @@ -2045,7 +2056,7 @@ jobs:
# 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_TAKEOVER}" != "true" && "${LIVE_AUTHOR}" != "${AUTOFIX_BOT}" ]]; then INELIGIBLE='fork head without takeover (a non-bot fork needs the label)'
elif [[ "${LIVE_XREPO}" != "false" && "${LIVE_CAN_MODIFY}" != "true" ]]; then INELIGIBLE='fork head without maintainer-edit access'
elif [[ "${LIVE_XREPO}" != "false" ]]; then
# Live author-privilege re-check for fork targets: an author who
Expand Down
108 changes: 71 additions & 37 deletions scripts/tests/qwen-autofix-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,9 +655,25 @@ describe('qwen-autofix workflow', () => {
expect(
runRecheck(pr({ labels: [{ name: 'autofix/skip' }] })).out,
).toContain('stale=true');
// Fork heads: manageable ONLY with takeover + allow-edits + a fork
// author who holds write+ LIVE; anything less discards.
// Fork heads: manageable with allow-edits + a fork author who holds write+
// LIVE, PLUS either the takeover label (non-bot forks) OR bot authorship
// (the bot's own fork needs no label). Anything less discards.
// A bot fork with no allow-edits still discards.
expect(runRecheck(pr({ isCrossRepository: true })).passed).toBe(false);
// The bot's OWN fork with allow-edits is eligible WITHOUT a label — the
// author check already exempts the bot, and the fork chain no longer
// demands a label for it. (head repo matches the HEAD_REPO env.)
const botFork = pr({
isCrossRepository: true,
maintainerCanModify: true,
headRepositoryOwner: { login: 'maint-fork' },
headRepository: { name: 'qwen-code' },
});
expect(runRecheck(botFork).passed).toBe(true);
// Remove allow-edits and the same bot fork discards (cannot push).
expect(runRecheck({ ...botFork, maintainerCanModify: false }).passed).toBe(
false,
);
const forkPr = pr({
isCrossRepository: true,
maintainerCanModify: true,
Expand Down Expand Up @@ -1035,54 +1051,72 @@ 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.
// Fork candidates are unioned from TWO sources: the bot's own forks
// (bot-prs.json is --author AUTOFIX_BOT, so a fork there is the bot's own
// work and needs NO label) and takeover-LABELED forks (takeover-prs.json,
// any eligible author). Both require allow-edits and no skip; 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"\)/,
/done < <\(jq -rs --arg skip "\$\{SKIP_LABEL\}" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/bot-prs\.json" "\$\{WORKDIR\}\/takeover-prs\.json"\)/,
)?.[1]
?.replace(/\n {14}/g, '\n');
expect(forkSel).toBeTruthy();
const forkRows = execFileSync(
'jq',
['-r', '--arg', 'skip', 'autofix/skip', forkSel],
['-rs', '--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' },
},
]),
input:
// bot-prs.json (all --author qwen-code-dev-bot)
JSON.stringify([
{
number: 20,
isCrossRepository: true,
maintainerCanModify: true,
labels: [], // no label — admitted anyway, it's the bot's own fork
author: { login: 'qwen-code-dev-bot' },
},
{
number: 19,
isCrossRepository: true,
maintainerCanModify: false, // no allow-edits — the bot cannot push
labels: [],
author: { login: 'qwen-code-dev-bot' },
},
{
number: 18,
isCrossRepository: false, // in-repo bot PR — not a fork candidate
maintainerCanModify: true,
labels: [],
author: { login: 'qwen-code-dev-bot' },
},
]) +
// takeover-prs.json (--label autofix/takeover)
JSON.stringify([
{
number: 9,
isCrossRepository: true,
maintainerCanModify: true,
labels: [{ name: 'autofix/takeover' }],
author: { login: 'maint-a' },
},
{
number: 7,
isCrossRepository: true,
maintainerCanModify: true,
labels: [{ name: 'autofix/takeover' }, { name: 'autofix/skip' }],
author: { login: 'maint-c' },
},
]),
},
)
.trim()
.split('\n');
expect(forkRows).toEqual(['9\tmaint-a']);
// unique_by(.number) sorts ascending: the labeled human fork (#9) and the
// bot's own unlabeled fork (#20); #19 (no allow-edits), #18 (in-repo), and
// #7 (skip) are dropped.
expect(forkRows).toEqual(['9\tmaint-a', '20\tqwen-code-dev-bot']);
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
Expand Down
Loading