mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-26 00:53:48 +00:00
* feat(autofix): escalate stopped takeover PRs and age out unanswered pauses
Takeover PRs that hit the round cap (or a circuit breaker) went silent:
no label, no dashboard entry, no escalation — five PRs had been paused
for days. The fleet shepherd only tracked bot-authored PRs, so the whole
35-PR human takeover pool was invisible.
The autofix scan now applies an autofix/needs-human label whenever a PR
reaches its cap (the write rides every cap detection, so already-paused
PRs backfill on the regular scan rotation), and removes it wherever
management resumes or a human releases the PR. The fleet shepherd
enumerates the takeover pool onto its dashboard (state, stop reason,
pause age, plus an awaiting-human section for released PRs) and gains a
single bounded lever: a takeover whose pause went unanswered for
AUTO_RELEASE_DAYS days gets its takeover label removed with a bilingual
summary, keeping the needs-human label as the filterable TODO. Resume
evidence newer than the pause notice — bot markers, trusted re-arm
commands, fresh labeled events — vetoes the release; every read fails
closed and a per-tick cap bounds blast radius.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): harden the takeover auto-release against review round 1
Addresses the PR review's two Criticals and eleven Suggestions:
- Command-comment resume evidence now counts only while FRESH (2h grace)
and UNSUPERSEDED by a refusal ack (fork-refused/base-refused/
skip-blocked) — an ignored command expires instead of vetoing the
release forever, and no permission logic is mirrored from the route.
- The release lever's population comes from the needs-human enumeration
(needs-human ∩ takeover), never the display window; both enumerations
cap at 100 with saturation warnings, and a failed enumeration degrades
to an error row so the dashboard write (and its liveness watermark)
always runs.
- The auto-release summary posts before the label DELETE, dedup'd by its
own marker — neither half can strand the other on a transient failure.
- Awaiting-human rows use neutral wording (capped bot PRs land there too)
and a shepherd-side heal clears stale needs-human labels left by manual
UI releases on fork PRs (human unlabeled event, budgeted, skip-vetoed).
- Fail-closed deferrals now still render a dashboard row (the row append
moved outside the evaluation arms); tick summary and dashboard header
report the same counters; days_since() replaces pasted epoch math.
- Tests: command-evidence gate replays (fresh/refused/expired/acked),
refusal-variant and command-string cross-file pins, DELETE-target and
fallback-assignment pins, heal jq replays, unified-row-render pin.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 2 — cycle-scoped heal, retryable summary
- The stale-label heal now only counts a human unlabel NEWER than the
latest label-apply, so an unlabel from an earlier takeover cycle can no
longer heal the current cycle's needs-human after an auto-release (R2-1).
- The summary dedup marker is scoped to the current pause cycle (markers
older than the latest cap notice are ignored), so a re-armed and
re-capped PR still gets its second release summary (R2-4).
- The two DELETE levers no longer redirect act()'s stdout, keeping the
DRY-RUN preview and failure warning visible (R2-5).
- AUTO_RELEASE_DAYS is base-10 normalized after the numeric guard, so a
zero-padded repo variable can't silently kill the lever (R2-6).
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 2 — cycle-scoped markers and mutation-tested pins
- Heal is cycle-correlated: only a human unlabel NEWER than the latest
label-apply counts (an earlier cycle's unlabel can't heal this cycle).
- The release summary dedup marker is scoped to the current pause cycle,
so a re-armed and re-capped PR still gets its second summary.
- act() stdout is no longer redirected on the two DELETE levers (DRY-RUN
preview and failure warning stay visible).
- AUTO_RELEASE_DAYS is base-10 normalized so a zero-padded repo variable
cannot silently kill the lever.
- Doc/workflow-header text corrected to the implemented order (summary
first, marker-dedup'd) and to the idle-backoff backfill timing.
- Mutation-tested test pins for every gap the reviewer probed: days_since
replay, NH_PREFIX interpolation + truth map, loop-1 deferral, full
cross-file marker/refusal-set equality, label-constant cross-pin,
EVENT_TS merge + promotion ordering, CLEANUPS increment, unclassified
headline classification, filter byte-identity, exit-spelling ban,
@uri encoding, sort/field-list attribution, paginate shapes, scope
--arg bindings, LIVE_LABELS_JSON wiring, positional append pin,
label-create idempotence + POST guard, and per-branch removal
attribution in the toggle replay.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 3 — release-ack label gate and shared classifiers
- R3-1 (Critical): the every-scan cap-branch label POST is now suppressed
when a release ack (takeover-ack released) is newer than the last re-arm,
so a released bot PR is not re-labeled each scan (which would fight every
release-side removal and ping-pong with the shepherd cleanup). A re-arm
advances the window past the release ack, re-enabling the label.
- R3-7: the /retry re-arm's needs-human removal now honors autofix/skip,
mirroring the takeover-command guard — a frozen PR keeps its only
filterable escalation state.
- R3-2 (Critical): the takeover-enum error row no longer claims 'no release
evaluation ran' — the lever is fed by the needs-human enumeration.
- R3-8: the conflict-dispatch lever refuses a paused (needs-human) PR
instead of spending a dispatch slot the scan would refuse.
- R1-10: extracted pending_checks()/failed_test_url() helpers so both
dashboard loops share one CI-status classifier (the round-1 reply was
wrong that the restructure removed this duplication — it did not).
- Hardened the mutation-tested pins: exact terminal-headline count (5),
full rearm DELETE line + single-API-write, AUTO_RELEASE_DAYS guard order,
runRearm env/stub/assertion for the /retry DELETE + skip guard, and the
scope-guard comparison operator.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 4 — label-lifecycle hardening
- R4-C1: the conflict-dispatch lever reads needs-human from the LIVE label
payload (after live_skip), not the tick-start snapshot, so a label applied
after enumeration is still honored.
- R4-C2: a re-armed PR that still carries needs-human (a resume-side removal
failed) now gets a bounded, skip-vetoed cleanup retry instead of staying
pinned in the paused population forever.
- R4-C3: the per-tick release budget is consumed before the first external
write — a DELETE outage can no longer mutate many PRs while RELEASES=0.
- R4-C4: dashboard row routing follows post-action label state — a released
PR moves to Awaiting human, a healed one drops off entirely.
- R4-C5: the AUTO_RELEASE_DAYS guard also rejects over-long digit strings
before any arithmetic (Bash-int overflow would wrap negative and pass -ge).
- R4-32: takeover-command stop only removes needs-human when the takeover
release actually landed (REMOVED_OK; 404 counts) — a failed release no
longer strands the escalation label while latching RELEASE_ACKED.
- R4-2: the /retry skip guard fails closed — an unreadable label state keeps
the label (mirrors takeover-ack's exit-1 convention).
- R4-3: the takeover-ack released arm and the stop branch both honor
autofix/skip when removing needs-human.
- R4-S1: producer headlines must be explicitly classified terminal or
transient — an unclassified headline now fails the cross-file test.
- Pins updated/added for every behavior above.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 4 — robust release detection and marker-true gates
- R4-1/R4-5: release detection now uses the takeover unlabeled EVENT
(recorded on every removal path, unlike the tolerated-lost ack comment),
and the suppression only applies to human-authored PRs — a bot PR
released from takeover returns to standard management and keeps the cap
notice + escalation label.
- R4-6: the conflict-dispatch lever requires marker truth (conflict_paused)
— an armed PR with a stale needs-human label is dispatched normally.
- R4-C1: the pause check reads needs-human from the live label payload.
- R4-C2: re-armed PRs with a stale label get a bounded cleanup retry.
- R4-C3: the release budget is consumed before the first external write.
- R4-C4: dashboard rows route on post-action label state.
- R4-C5: AUTO_RELEASE_DAYS rejects over-long digit strings before arithmetic.
- R4-32/R4-2/R4-3: stop/ack/retry removal paths gate on REMOVED_OK and skip.
- R4-9/R4-10/R4-13: membership check, STATE escaping, HM_OK-branched error row.
- R4-14: command evidence requires a write/maintain/admin commenter.
- R4-11/R4-15/R4-24: behavioral replays for the classifiers, the release
jq, and the gate nesting.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): keep the label-DELETE idiom byte-identical across workflows
R4-32's REMOVED_OK tracking reworked the takeover-command stop branch's
404-tolerance block, breaking the pr-self-report-label ↔ qwen-autofix
contract test that pins the two workflows' label-DELETE idiom
byte-identical. Keep the canonical idiom and derive REMOVED_OK from
REMOVE_ERR's content afterward (empty = landed, 404 = already off,
anything else = release did not land) — same behavior, contract intact.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* test(autofix): close review round 4b — mutation-tested harness hardening
- R4-4: re-bound the conflict-lever regex spans and anchor on
conflict_paused so the pin can't resolve live_skip against the sync
lever's call site.
- R4-16: runAck records gh calls and asserts per-branch needs-human DELETE
counts (engaged/released=1; base-refused/skip=0).
- R4-17: pin the first-pickup scan DELETE inside the engage-ack success
branch.
- R4-18/R4-19: ordering pins — takeover POST before needs-human DELETE
(engage), marker comment before cleanup DELETE (/retry).
- R4-20: deleteFail stub branch replays non-404 (warns, status 0) and 404
(silent) DELETE outcomes.
- R4-21: identity-failure paths assert no DELETE ran.
- R4-22: runRearm stub serves labels only when --json labels is requested.
- R4-23: full api-write census pinned (exactly api user + one DELETE).
- R4-25: skip fixture uses the production multi-label shape.
- R4-28: loop-2 fetch pins include the jq -s 'add // []' merge program.
- R4-29: cmdGate scenario where a refusal is OLDER than the fresh command.
- R4-30: takeoverEnum asserts its own sort:updated-asc qualifier.
- R4-31: multi-entry fixtures pin the max/last/length aggregation operators
on CMD_TS, EVENT_TS, REASON, SUMMARY_POSTED, and the heal lever's
LATEST_LABEL_TS/UNLABEL_ACTOR programs.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 5 — trust boundaries and evidence freshness
- R4-5 residual: PR_META now fetches author so IS_BOT_AUTHOR actually
resolves (the exemption was dead on arrival), with a behavioral replay.
- R5-1: REMOVED_OK derives from the captured stream ('HTTP ' non-404 = not
landed) instead of output emptiness — GitHub returns a body on success.
- R5-2: /retry only drops needs-human when management actually resumes
(takeover label present or bot-authored) — an auto-released human PR
keeps its escalation label.
- R5-3: conflict_paused requires a real cap notice AND a newer resume
marker — label-present/notice-absent now fails closed toward paused.
- R5-4: a failed permission read defers the release (PERM_READ_FAILED),
never counts as no-permission — at both evaluation points.
- R5-5: compute_resume_ts scans in-grace commands newest-first and
permission-checks each (≤2 reads), so a stranger's echo can't shadow a
maintainer's command.
- R5-6: the release branch re-fetches evidence and recomputes resume state
immediately before the first write.
- R5-8: the heal re-checks the takeover label from the live payload before
clearing needs-human.
- R5-9: same-second ties resolve toward resume/release suppression in both
files (RESUME>=TERM; RELEASE_ACKED >= window).
- R5-10: the heal anchors to the current pause boundary (latest needs-human
apply event); an absent anchor skips the cleanup, fail closed.
- Tests: whole-function compute_resume_ts replay (permission/shadow/tie/
grace/refusal cases), heal anchor fixtures, toggle stub models the real
DELETE body, runRearm orphan case.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 6 — contract-safe gates and subshell flag fix
- B5: the bot-fleet enumeration failure now degrades to a loud error row
and falls through (FLEET_OK gate) instead of exiting before the
independently-fed takeover/needs-human processing and the dashboard's
liveness-watermark write.
- B12: the cap-branch LIVE_LABELS consent re-read fails closed on an
unreadable gh pr view (a collapse to '' ignored a concurrently added
skip for standard bot PRs).
- R6-1/R6-19: the takeover-release landed flag is keyed on the DELETE exit
status (LBL_DEL_FAILED set inside the pinned idiom's failure branch) —
never on output text, which lies in both directions. The
pr-self-report-label idiom evolves identically to keep the cross-workflow
contract green (and its own 'removed' log line no longer lies either).
- R5-4 residual: compute_resume_ts now returns via globals
(RESUME_OUT/PERM_READ_FAILED) and both call sites invoke it directly —
the previous subshell silently dropped PERM_READ_FAILED, leaving
the fail-closed defer branches dead.
- R6-3: command candidates are deduped by author before permission reads,
so a stranger posting N commands can't burn the 2-read budget and shadow
a maintainer's command.
- R6-4: an unreadable release history is reported as such, not as
'released'.
* fix(autofix): close review round 7 — lever starvation, re-arm anchoring, permission shadows
- R5-7: the release lever gets its OWN enumeration of the paused population
(takeover+needs-human, stale-first) instead of the long-lived needs-human
display window — released-awaiting PRs aging back into that window could
truncate exactly the fresh pauses that become release-eligible, starving
the lever and making the zombie state permanent and self-feeding.
- R6-3: the 2-read permission budget now sets PERM_READ_FAILED on exhaustion
(it was failing open), and the candidate walk sorts newest-first per author
(group_by+max_by+sort) instead of unique_by's alphabetical order, so two
read-only strangers can't shadow a maintainer's newer command.
- R7-1: the stale-label cleanup anchors on the current pause boundary (latest
needs-human apply) and is marker-confirmed only — not keyed on TERM_TS, and
never on command/label evidence — so a re-paused PR with a lost cycle-2
notice isn't read as re-armed on stale cycle-1 evidence.
- R7-7: the /takeover stop success echo is gated on REMOVED_OK — a failed
DELETE no longer logs 'removed'.
- R7-2: TAKEOVER_COMMAND/RETRY_COMMAND mirrored into the shepherd env and
passed via --arg, so the resume matcher can't drift from the route.
- Tests: conflict_paused + re-arm guard behavioral replays, mirrored-command
cross-file pin, engaged/released-with-skip ack matrix cells, LBL_DEL_FAILED
branching, gnuDateShim hoisted to module scope, R4-24 nesting indices.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* test(autofix): complete the R4-16 ack-matrix DELETE-count coverage
Add fork-refused and skip-blocked ack cases to the takeover-ack harness
— management never resumed on either, so zero needs-human DELETEs, each
asserted by total DELETE count (not just toContain).
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 8 — reachable re-arm cleanup, release race guards, pin census
* fix(autofix): close review round 9 — honest release-failed ack, cleanup attempt budgets, dashboard single-owner routing
- /takeover stop whose label DELETE failed no longer posts a
'Takeover released' ack: a release-failed variant names the retry
(R9-4), and the R7-7 echo pair gains symmetric log pins (R9-3)
- stale-label cleanups count ATTEMPTS like the release budget, so a
DELETE outage trips the cap instead of leaving it inert (R9-5)
- dashboard renders each both-label PR exactly once: loop 1 defers by
paused membership, loop 3 is the render of last resort (R9-1/R9-13)
- a 404 from the collaborators-permission endpoint classifies the
author read-only instead of renewably deferring the release (R9-10)
- cap-branch release evidence reuses the per-iteration events fetch
under a success flag (R9-18); release-clock comment corrected (R9-11)
- harness gates end-anchor the --json field list (R9-14/R9-15); the
escalation POST and the ack-body census gain count pins (R9-16);
the date shim answers only the +%s shape it emulates (R9-9)
* fix(autofix): close review round 10 Criticals — exact HTTP 404 release classification, isolated replay fixtures
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(autofix): close review round 11 Criticals — engaged stale-ack guard, exact HTTP 404 permission classification
---------
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
401 lines
16 KiB
JavaScript
401 lines
16 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { parse } from 'yaml';
|
|
|
|
const workflow = parse(
|
|
readFileSync('.github/workflows/pr-self-report-label.yml', 'utf8'),
|
|
);
|
|
const runBlock = workflow.jobs.label.steps[0].run;
|
|
|
|
// The step decides label state from two inputs: the logins that opened the PR's
|
|
// closed issues (gh api graphql) and whether the label is already present (gh pr
|
|
// view). Stub both and record which mutation, if any, it makes. The
|
|
// GH_DELETE_FAILS / GH_POST_FAILS knobs make the matching REST call exit 1
|
|
// with the knob value on stderr (shaped like a real gh HTTP error), so BOTH
|
|
// failure policies the workflow introduces are pinned — DELETE tolerance and
|
|
// POST loudness — instead of the catch-all silently exiting 0.
|
|
const GH_STUB = [
|
|
'#!/usr/bin/env bash',
|
|
'echo "gh $*" >> "${CALLS_LOG}"',
|
|
'case "$*" in',
|
|
" *'api graphql'*) [ \"${GH_API_FAILS:-false}\" = true ] && exit 1; printf '%s\\n' ${GH_ISSUE_AUTHORS:-} ;;",
|
|
" *'pr view'*labels*) { [ \"${GH_HAS_LABEL:-false}\" = true ] && printf 'true' || printf 'false'; } ;;",
|
|
' *\'api -X DELETE\'*) [ -z "${GH_DELETE_FAILS:-}" ] || { printf \'%s\' "${GH_DELETE_FAILS}" >&2; exit 1; } ;;',
|
|
' *\'api -X POST\'*) [ -z "${GH_POST_FAILS:-}" ] || { printf \'%s\' "${GH_POST_FAILS}" >&2; exit 1; } ;;',
|
|
' *) : ;;',
|
|
'esac',
|
|
'exit 0',
|
|
].join('\n');
|
|
|
|
// The replayed DELETE URI-encodes the slashed label with a REAL jq — stub it
|
|
// on PATH like gh so the suite never depends on the host having jq (a fresh
|
|
// macOS has none, and the %2F assertion would fail opaquely). Supports only
|
|
// the one form the workflow executes — and ENFORCES it, program included:
|
|
// jq -rn --arg NAME VALUE '$NAME|@uri'. Any other program exits 1, so a
|
|
// mutation of the workflow's filter (e.g. dropping `|@uri`) fails the suite
|
|
// instead of riding the stub's unconditional percent-encoding.
|
|
const JQ_STUB = [
|
|
'#!/usr/bin/env bash',
|
|
// The FULL argv is enforced, not just the program: `-rn` is what makes jq
|
|
// ignore the empty stdin of a run step (with `-r` alone it evaluates zero
|
|
// inputs and prints nothing), and the binding NAME must be `l` or real jq
|
|
// exits 3 on `$l is not defined`. Either mutation expands the command
|
|
// substitution to an empty string, the DELETE hits …/labels/ with no name
|
|
// segment, and the 404 race-tolerance swallows it — silent green.
|
|
'[[ $# -eq 5 && "$1" == "-rn" && "$2" == "--arg" && "$3" == "l" && "$5" == \'$l|@uri\' ]] || exit 1',
|
|
'value="$4"',
|
|
'out=""',
|
|
'for ((i = 0; i < ${#value}; i++)); do',
|
|
' c="${value:i:1}"',
|
|
' if [[ "$c" =~ [A-Za-z0-9._~-] ]]; then out+="$c"; else printf -v hex \'%02X\' "\'$c"; out+="%$hex"; fi',
|
|
'done',
|
|
'printf \'%s\' "$out"',
|
|
].join('\n');
|
|
|
|
describe('pr-self-report-label', () => {
|
|
const run = ({
|
|
prAuthor = 'alice',
|
|
issueAuthors = '',
|
|
hasLabel = false,
|
|
apiFails = false,
|
|
deleteFails = '',
|
|
postFails = '',
|
|
}) => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'lbl-'));
|
|
const bin = join(dir, 'bin');
|
|
mkdirSync(bin);
|
|
const callsLog = join(dir, 'calls.log');
|
|
writeFileSync(join(bin, 'gh'), GH_STUB);
|
|
chmodSync(join(bin, 'gh'), 0o755);
|
|
writeFileSync(join(bin, 'jq'), JQ_STUB);
|
|
chmodSync(join(bin, 'jq'), 0o755);
|
|
const out = execFileSync('bash', ['-c', runBlock], {
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}:${process.env.PATH}`,
|
|
CALLS_LOG: callsLog,
|
|
REPO: 'o/r',
|
|
PR: '1',
|
|
PR_AUTHOR: prAuthor,
|
|
LABEL: 'review/self-reported',
|
|
GH_ISSUE_AUTHORS: issueAuthors,
|
|
GH_HAS_LABEL: String(hasLabel),
|
|
GH_API_FAILS: String(apiFails),
|
|
GH_DELETE_FAILS: deleteFails,
|
|
GH_POST_FAILS: postFails,
|
|
},
|
|
encoding: 'utf8',
|
|
});
|
|
const calls = existsSync(callsLog) ? readFileSync(callsLog, 'utf8') : '';
|
|
rmSync(dir, { recursive: true, force: true });
|
|
return {
|
|
// Label mutations are REST — `gh pr edit`'s GraphQL lookup requests
|
|
// repository.pullRequest.projectCards, which GitHub rejects on the gh
|
|
// builds that still send it (this job's runner image does), so it
|
|
// exits 1 before mutating (43 straight CI failures of the add/remove
|
|
// arms, 2026-08-04..08). The full method + path is asserted,
|
|
// including the %2F: the label is a PATH SEGMENT in the DELETE and
|
|
// contains a slash, so an unencoded call 404s.
|
|
added:
|
|
/api -X POST repos\/o\/r\/issues\/1\/labels -f labels\[\]=review\/self-reported/.test(
|
|
calls,
|
|
),
|
|
removed:
|
|
/api -X DELETE repos\/o\/r\/issues\/1\/labels\/review%2Fself-reported/.test(
|
|
calls,
|
|
),
|
|
labelCreated: /label create/.test(calls),
|
|
out: out.trim(),
|
|
};
|
|
};
|
|
|
|
it('labels a PR that closes an issue its own author opened', () => {
|
|
expect(run({ prAuthor: 'alice', issueAuthors: 'alice' })).toMatchObject({
|
|
added: true,
|
|
removed: false,
|
|
labelCreated: true,
|
|
});
|
|
// One self-reported issue among several linked is enough.
|
|
expect(
|
|
run({ prAuthor: 'alice', issueAuthors: 'bob\nalice\ncarol' }).added,
|
|
).toBe(true);
|
|
});
|
|
|
|
it('removes the label once no closed issue is self-reported', () => {
|
|
// The link was re-pointed to someone else's issue…
|
|
expect(
|
|
run({ prAuthor: 'alice', issueAuthors: 'bob', hasLabel: true }),
|
|
).toMatchObject({ added: false, removed: true });
|
|
// …or removed entirely.
|
|
expect(
|
|
run({ prAuthor: 'alice', issueAuthors: '', hasLabel: true }).removed,
|
|
).toBe(true);
|
|
});
|
|
|
|
it('makes no change when the label already matches the state', () => {
|
|
expect(
|
|
run({ prAuthor: 'alice', issueAuthors: 'alice', hasLabel: true }),
|
|
).toMatchObject({ added: false, removed: false });
|
|
expect(
|
|
run({ prAuthor: 'alice', issueAuthors: 'bob', hasLabel: false }),
|
|
).toMatchObject({ added: false, removed: false });
|
|
// A PR that closes no issue is never labelled.
|
|
expect(
|
|
run({ prAuthor: 'alice', issueAuthors: '', hasLabel: false }),
|
|
).toMatchObject({ added: false, removed: false });
|
|
});
|
|
|
|
it('fails open: a GraphQL failure never adds or removes the label', () => {
|
|
// gh api graphql 5xx → API_OK=false. The label state is unknown, so the
|
|
// step must leave it untouched rather than read empty results as
|
|
// "no self-reported link" and strip a correct label.
|
|
expect(
|
|
run({ prAuthor: 'alice', hasLabel: true, apiFails: true }),
|
|
).toMatchObject({ added: false, removed: false });
|
|
expect(
|
|
run({ prAuthor: 'alice', hasLabel: false, apiFails: true }),
|
|
).toMatchObject({ added: false, removed: false });
|
|
});
|
|
|
|
it('pins the REST failure policies: 404 race silent, other DELETEs warned, POST loud', () => {
|
|
// DELETE: the presence check is not atomic with the DELETE — a 404 race
|
|
// means the desired end state already holds: green, no warning, and the
|
|
// removal is logged.
|
|
const race = run({
|
|
prAuthor: 'alice',
|
|
issueAuthors: 'bob',
|
|
hasLabel: true,
|
|
deleteFails: 'HTTP 404: Not Found (https://api.github.com/…)',
|
|
});
|
|
expect(race.removed).toBe(true);
|
|
expect(race.out).not.toContain('::warning::');
|
|
// R7-5: a 404 race means the end state holds → the success claim fires,
|
|
// the "did not land" claim must not.
|
|
expect(race.out).toContain('removed');
|
|
expect(race.out).not.toContain('removal did not land');
|
|
// Any other DELETE failure keeps the step green (the job only re-runs
|
|
// on the next PR event) but MUST surface — a silent run would claim
|
|
// "removed" while the label stays on the PR.
|
|
const failed = run({
|
|
prAuthor: 'alice',
|
|
issueAuthors: 'bob',
|
|
hasLabel: true,
|
|
deleteFails: 'HTTP 500',
|
|
});
|
|
expect(failed.removed).toBe(true);
|
|
expect(failed.out).toContain('::warning::');
|
|
expect(failed.out).toContain('removal failed');
|
|
expect(failed.out).toContain('HTTP 500');
|
|
// R7-5: the LBL_DEL_FAILED branching — a failed DELETE must NOT claim
|
|
// the label was removed (the lying-log shape this change exists to kill).
|
|
expect(failed.out).toContain('removal did not land');
|
|
expect(failed.out).not.toContain('removed');
|
|
// R10-1/R10-3: the tolerated token is exactly "HTTP 404" — a transport
|
|
// failure embedding a 404-bearing request URL must warn and report
|
|
// not-landed, never read as the already-off case (the loose *404*
|
|
// match hit the URL's PR number and claimed "removed").
|
|
const transport = run({
|
|
prAuthor: 'alice',
|
|
issueAuthors: 'bob',
|
|
hasLabel: true,
|
|
deleteFails:
|
|
'gh: Delete "https://api.github.com/repos/o/r/issues/4041/labels/review%2Fself-reported": dial tcp 140.82.121.4:443: connect: connection refused',
|
|
});
|
|
expect(transport.removed).toBe(true);
|
|
expect(transport.out).toContain('::warning::');
|
|
expect(transport.out).toContain('removal failed');
|
|
expect(transport.out).toContain('removal did not land');
|
|
expect(transport.out).not.toContain('removed');
|
|
// POST carries NO tolerance: a label that fails to apply must fail the
|
|
// step (the runBlock sets -e), not leave the PR unlabeled behind green.
|
|
expect(() =>
|
|
run({ prAuthor: 'alice', issueAuthors: 'alice', postFails: 'HTTP 500' }),
|
|
).toThrow();
|
|
});
|
|
|
|
it('never interpolates PR-controlled data into the run body (env only)', () => {
|
|
// pull_request_target hardening: the author/number/label reach the script
|
|
// only through env, so a crafted title or body cannot inject shell.
|
|
expect(runBlock).not.toMatch(/\$\{\{/);
|
|
});
|
|
});
|
|
|
|
// Scans the DECODED `run:` scripts of one parsed workflow for `gh pr edit`
|
|
// label mutations — what bash will actually execute, not the YAML surface.
|
|
// Scanning raw lines was evaded three ways (all reproduced): a `#` inside a
|
|
// quoted string ate the trailing backslash, a wrap INSIDE the command
|
|
// prefix (`gh \` / `pr edit`, or `--add-\` / `label`) never re-joined into
|
|
// the literal the regex wanted, and a folded scalar (`run: >`) has no
|
|
// backslashes at all. Parsing the YAML resolves folding; joining
|
|
// continuations the way bash does (backslash-newline removed, nothing
|
|
// inserted) resolves the wraps; the whitespace-tolerant regex resolves the
|
|
// prefix splits. Only WHOLE-line comments are stripped — a `#` mid-line may
|
|
// sit inside a quoted string, and a comment quoting the ban documents it.
|
|
// Offenders are reported as file » job » step (never a line number: those
|
|
// stop corresponding to the file after joining).
|
|
function ghPrEditLabelOffenders(file, raw) {
|
|
const banned = /gh\s+pr\s+edit[\s\S]*?(--add-label|--remove-label)/;
|
|
const offenders = [];
|
|
const doc = parse(raw);
|
|
for (const [jobId, job] of Object.entries(doc?.jobs ?? {})) {
|
|
(job?.steps ?? []).forEach((step, i) => {
|
|
if (typeof step?.run !== 'string') return;
|
|
const script = step.run
|
|
.split('\n')
|
|
.filter((l) => !/^\s*#/.test(l))
|
|
.join('\n')
|
|
.replace(/\\\n/g, '');
|
|
if (banned.test(script)) {
|
|
offenders.push(`${file} » ${jobId} » ${step.name ?? `step ${i}`}`);
|
|
}
|
|
});
|
|
}
|
|
return offenders;
|
|
}
|
|
|
|
// The argv form (`execFileSync('gh', ['pr', 'edit', …, '--add-label'])`) is
|
|
// how scripts under .github/scripts spell the same broken command — the
|
|
// release path shipped it silently for months behind continue-on-error.
|
|
function ghPrEditLabelArgvOffenders(file, raw) {
|
|
return /['"]pr['"],\s*['"]edit['"][\s\S]{0,400}?(--add-label|--remove-label)/.test(
|
|
raw,
|
|
)
|
|
? [file]
|
|
: [];
|
|
}
|
|
|
|
describe('gh pr edit label mutations are banned in workflow files', () => {
|
|
// `gh pr edit` label mutations fail wherever the gh build still requests
|
|
// repository.pullRequest.projectCards: GitHub answers the Projects
|
|
// (classic) deprecation as an error and the command exits 1 before
|
|
// applying the change. Three workflows carried label mutations through it
|
|
// and every such arm failed silently-green (the runs only passed when
|
|
// there was nothing to do) or warned with a misdiagnosis. Labels go
|
|
// through the REST issues/labels endpoints, which never touch that query.
|
|
// Scope is workflow YAML: .github/scripts/classify-release-notes.mjs still
|
|
// toggles skip-changelog-auto through `gh pr edit` on the release path and
|
|
// needs its own conversion.
|
|
it('catches every executable shape and ignores documentation', () => {
|
|
const wf = (run) =>
|
|
`jobs:\n j:\n steps:\n - name: s\n run: |\n${run
|
|
.split('\n')
|
|
.map((l) => ` ${l}`)
|
|
.join('\n')}\n`;
|
|
// A comment quoting the banned pattern documents the ban — no offence.
|
|
expect(
|
|
ghPrEditLabelOffenders(
|
|
'w.yml',
|
|
wf('# do not revert to gh pr edit --add-label here\necho ok'),
|
|
),
|
|
).toEqual([]);
|
|
// Plain violation.
|
|
expect(
|
|
ghPrEditLabelOffenders('w.yml', wf('gh pr edit 1 --add-label x')),
|
|
).toEqual(['w.yml » j » s']);
|
|
// Evasion 1 (reproduced by review): a `#` inside a quoted string on the
|
|
// first line of a continuation — per-line comment stripping ate the
|
|
// trailing backslash and never joined the halves.
|
|
expect(
|
|
ghPrEditLabelOffenders(
|
|
'w.yml',
|
|
wf(
|
|
'echo "🏷️ #${PR}: applying" && gh pr edit "${PR}" \\\n --add-label "${LABEL}"',
|
|
),
|
|
),
|
|
).toEqual(['w.yml » j » s']);
|
|
// Evasion 2: a wrap INSIDE the command prefix and inside a flag token —
|
|
// bash joins backslash-newline with nothing inserted.
|
|
expect(
|
|
ghPrEditLabelOffenders('w.yml', wf('gh \\\n pr edit 1 --add-label x')),
|
|
).toEqual(['w.yml » j » s']);
|
|
expect(
|
|
ghPrEditLabelOffenders('w.yml', wf('gh pr edit 1 --add-\\\nlabel x')),
|
|
).toEqual(['w.yml » j » s']);
|
|
// Evasion 3: a folded scalar has no backslashes on any physical line.
|
|
expect(
|
|
ghPrEditLabelOffenders(
|
|
'w.yml',
|
|
'jobs:\n j:\n steps:\n - name: s\n run: >\n gh pr edit 1\n --add-label x\n',
|
|
),
|
|
).toEqual(['w.yml » j » s']);
|
|
// The argv form used by .mjs scripts.
|
|
expect(
|
|
ghPrEditLabelArgvOffenders(
|
|
's.mjs',
|
|
"execFileSync('gh', ['pr', 'edit', n, '--repo', r, '--add-label', L]);",
|
|
),
|
|
).toEqual(['s.mjs']);
|
|
expect(
|
|
ghPrEditLabelArgvOffenders(
|
|
's.mjs',
|
|
"execFileSync('gh', ['api', '-X', 'POST', `repos/x/issues/1/labels`]);",
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('no workflow or script mutates labels through gh pr edit', () => {
|
|
const offenders = [];
|
|
for (const file of readdirSync('.github/workflows').filter((f) =>
|
|
/\.ya?ml$/.test(f),
|
|
)) {
|
|
offenders.push(
|
|
...ghPrEditLabelOffenders(
|
|
file,
|
|
readFileSync(join('.github/workflows', file), 'utf8'),
|
|
),
|
|
);
|
|
}
|
|
for (const file of readdirSync('.github/scripts').filter((f) =>
|
|
/\.mjs$/.test(f),
|
|
)) {
|
|
offenders.push(
|
|
...ghPrEditLabelArgvOffenders(
|
|
file,
|
|
readFileSync(join('.github/scripts', file), 'utf8'),
|
|
),
|
|
);
|
|
}
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('REST DELETE idiom stays in sync across workflows', () => {
|
|
// The one non-obvious line — URI-encoding a slash-containing label as a
|
|
// DELETE path segment, plus the 404 race tolerance — exists in two
|
|
// workflows and cannot be shared (workflow run blocks have no include).
|
|
// This pin makes them drift-proof: normalize the label variable and the
|
|
// two must be byte-identical.
|
|
it('pr-self-report-label and qwen-autofix delete labels identically', () => {
|
|
const idiom = (file, labelVar) => {
|
|
const raw = readFileSync(join('.github/workflows', file), 'utf8');
|
|
const m = raw.match(
|
|
/if ! REMOVE_ERR="\$\(gh api -X DELETE[^\n]*\n[^\n]*404[^\n]*\n/,
|
|
);
|
|
expect(m, `${file}: DELETE idiom not found`).toBeTruthy();
|
|
return m[0]
|
|
.replace(new RegExp(labelVar, 'g'), 'LBL')
|
|
.replace(/^\s+/gm, '');
|
|
};
|
|
expect(idiom('pr-self-report-label.yml', 'LABEL')).toEqual(
|
|
idiom('qwen-autofix.yml', 'TAKEOVER_LABEL'),
|
|
);
|
|
});
|
|
});
|