forked from QwenLM/qwen-code
-
Notifications
You must be signed in to change notification settings - Fork 0
5427 lines (5294 loc) · 342 KB
/
Copy pathqwen-autofix.yml
File metadata and controls
5427 lines (5294 loc) · 342 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: 'Qwen Autofix'
# One workflow for the whole autonomous-fix lifecycle:
#
# issue → locate → fix → open PR (issue phase)
# open PR → review → triage → fix → push (review phase)
#
# The lifecycle is asynchronous — a PR is opened in one run and its review is
# addressed in a later run once a reviewer has weighed in — so each scheduled
# tick runs only the phase(s) that make sense, decided by the `route` job:
# • every 10m → review phase; issue phase only if no PR needs work
# • issues:labeled → issue phase when ready label, state, and sender match
# • pull_request_review → review phase for submitted feedback on bot PRs
# • pull_request:labeled → maintainer applies autofix/takeover → the loop
# manages that PR (human-authored included, and
# maintainer FORKS too: the fork's author must
# hold write+ live and the PR must allow
# maintainer edits — the bot then fetches/pushes
# the fork branch directly; org-owned forks
# cannot enable allow-edits → adoption instead);
# unlabeled releases it. autofix/skip opts any PR
# out everywhere and wins over takeover. Labels
# need GitHub triage+, so the permission gate is
# GitHub's own. The bot's OWN fork PRs (author ==
# the autofix bot, e.g. its codex flow) are auto-
# managed WITHOUT a label when allow-edits is on —
# they are the bot's own generated work, trust-
# equal to an in-repo bot PR; autofix/skip still
# opts them out.
# • issue_comment → '@qwen-code /takeover' (apply the label) and
# '@qwen-code /takeover stop' (remove it) — sugar
# for people without label access: the PR author,
# or write+ collaborators. Exact-match constants,
# and the ONLY side effect is the label toggle;
# engagement/release still happen exclusively via
# the label events, so manual labeling and the
# commands are the same single mechanism.
# • workflow_dispatch → force a phase, an issue, or a PR
#
# Every GitHub write (issue/PR comments, labels, branch push, PR create) goes
# through CI_DEV_BOT_PAT so the bot acts as the configured autofix identity.
# PAT label writes can emit issues:labeled events; the route guards below make
# those runs exit unless the label, issue state, and ready label all match.
on:
issues:
types:
- 'labeled'
- 'assigned'
pull_request_review:
types:
- 'submitted'
pull_request:
types:
- 'labeled'
- 'unlabeled'
issue_comment:
types:
- 'created'
schedule:
- cron: '*/10 * * * *' # Review first; issue fallback only when no PR needs work
workflow_dispatch:
inputs:
phase:
description: 'Which phase(s) to run'
required: false
default: 'auto'
type: 'choice'
options:
- 'auto' # review always; issue on schedule or ready-for-agent label
- 'issue' # locate + fix one bug only
- 'review' # address review on open PRs only
- 'both' # issue and review
issue_number:
description: 'Force a specific issue number (implies the issue phase)'
required: false
type: 'string'
pr_number:
description: 'Force a specific bot PR number (implies the review phase)'
required: false
type: 'string'
dry_run:
description: 'Assess/develop/address and verify, but do not claim, push, or comment'
required: false
type: 'boolean'
default: false
defaults:
run:
shell: 'bash'
permissions:
contents: 'read'
env:
# Identity of the autofix bot. All open in-repo PRs authored by this bot are
# eligible for the review phase — not limited to autofix/issue-* branches.
AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
# Branch-name prefix used by the issue phase when creating PRs. Also used
# for duplicate-PR detection and issue-number extraction from branch names.
BRANCH_PREFIX: 'autofix/issue-'
# The automated Qwen PR reviewer posts as this account; its review counts as
# actionable feedback even though it is not a human collaborator.
REVIEW_BOT: 'qwen-code-ci-bot'
# Human reviews/comments only count when the author is a real maintainer. This
# is the prompt-injection trust gate: feedback from anyone else is ignored so a
# hostile commenter cannot steer the agent.
TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]'
# Hard cap on automated review-address rounds per PR. After this the bot stops
# and leaves the PR for a human. Raised from 5: across the last 40 bot PRs
# only 3 ever reached the cap and all 3 merged AT it (one having spent two of
# its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling
# was near enough to bind on a bad day without any headroom for one. The cap
# exists to stop an unproductive LOOP, not to ration ordinary iteration —
# a genuinely stuck PR still stops, just later.
MAX_ROUNDS: '10'
# Suggestions may improve a PR, but continuing to implement them after ten
# change-producing rounds expands the diff and creates fresh review churn.
# From round 11 onward, only Critical findings, formally requested changes,
# failed checks, and base conflicts may drive code changes; lower-severity
# feedback is recorded and left open.
CRITICAL_ONLY_AFTER_ROUND: '10'
# Per-author tail budget inside Critical-only mode. An account is an
# ACCOUNTABILITY unit, not a throttle: a human login can host an automated
# reviewer loop with the exact regeneration property the review bot has
# (feedback re-generated after every push, at zero marginal cost). So the
# brake keys on measured regeneration, not identity: every source gets a
# bounded number of untagged feedback batches per counting window once
# Critical-only engages — the review bot's budget is zero (all deferred),
# a human's is this many CONSUMED batches. Past it, continuing requires
# one conscious act (**[Critical]**, a Request changes review, or /retry),
# which is precisely what separates intent from automation.
CRITICAL_ONLY_HUMAN_BATCHES: '2'
# An auth/access model error (401/402/403, "no access"/"does not exist")
# never self-heals - only a maintainer can fix the key - and every retry
# costs an agent run AND a PR comment. Cap those attempts far below
# MAX_ROUNDS so the actionable "check the model key" message lands in an
# hour instead of a day. Transient (429/5xx) errors keep the full budget.
API_AUTH_MAX_ROUNDS: '3'
# Checks the "wait for checks to settle" gate does NOT wait for. The gate
# exists so a FAILED check can be read as feedback, which is why build/test/
# lint are still waited for. `review-pr` is the LLM code review: its output
# is a REVIEW, delivered by its own real-time pull_request_review trigger and
# counted by the review path — the check conclusion carries nothing the loop
# acts on. Blocking on it bought nothing and cost a median 49 minutes per
# round (p90 123, max 158, over 32 completed runs), during which the PR was
# invisible to the scan even when it already had unaddressed feedback.
# Names must match job ids in qwen-code-pr-review.yml; a test pins that.
NON_BLOCKING_CHECKS: '["review-pr"]'
# Failed-check annotation patterns that mean the INFRASTRUCTURE died, not the
# code — a self-hosted runner losing the server, the disk filling, a runner
# shutdown, or a git fetch/clone dying mid-transfer. Such a check is red for a
# reason unrelated to the PR and clears on a re-run (observed: #7490's E2E
# "runner lost communication"; #6506's checkout "RPC failed; curl 92" /
# "fetch-pack: invalid index-pack output" — both green on the rerun). The scan
# auto-reruns those failed jobs ONCE, guarded by run_attempt so a persistent
# infra problem cannot loop. Deliberately conservative — only unambiguous
# machine/transport failures, never a bare test-level timeout, which could be
# a real regression (a co-present timeout does not block a match — one
# matching line classifies the run). Case-insensitive, vs the annotations.
INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed'
# Upper bound on review targets emitted per scan (fan-out defense-in-depth;
# excess is logged and deferred to the next scan).
MAX_TARGETS_PER_SCAN: '10'
# Upper bound on candidates INSPECTED per scan: idle candidates consume
# serial API calls even when they emit nothing, and takeover widens the
# candidate pool. Candidates are inspected NEWEST-first; past the budget
# the oldest tail defers — old quiet PRs are the least likely to hold new
# feedback, and a deferred PR with a live conflict is still picked up by
# the shepherd's conflict lever.
MAX_CANDIDATE_INSPECTIONS: '60'
# Maintainer-facing engagement labels (applying labels requires GitHub
# triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR —
# including a human-authored one — into the loop; SKIP opts any PR out
# everywhere, and wins when both are present.
TAKEOVER_LABEL: 'autofix/takeover'
SKIP_LABEL: 'autofix/skip'
# Comment-command sugar over TAKEOVER_LABEL ('<cmd>' applies it, '<cmd>
# stop' removes it). Matched EXACTLY against the trimmed comment body.
TAKEOVER_COMMAND: '@qwen-code /takeover'
# Re-arm sugar. Recovering a stranded PR previously meant DELETING the
# bot's autofix-eval marker comment by hand (undiscoverable, destructive,
# and it erases the audit trail). This command instead posts an
# 'autofix-rearm' marker that supersedes the earlier evaluation markers:
# the scan re-reads the feedback from scratch and the round counter resets.
RETRY_COMMAND: '@qwen-code /retry'
# Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely
# need dozens of feedback rounds — that is the point of takeover — so the
# unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays
# (a bot↔review-bot ping-pong is still bounded), it is just sized for
# explicitly delegated work; removing the label restores the strict cap,
# and re-engaging opens a fresh counting window (see REARM_KEY below).
TAKEOVER_MAX_ROUNDS: '100'
# Consecutive-failure sub-cap, distinct from the total round cap above. The
# total cap bounds how many PRODUCTIVE rounds a PR may take; this bounds how
# many rounds may fail IN A ROW with nothing pushed. Under takeover a PR gets
# up to 100 rounds, but a PR that fails to push this many times running is not
# iterating, it is stuck — a too-large / fast-conflicting PR whose fix keeps
# timing out or failing the gate. Retrying at the same budget will not fix
# that; a human has to rebase or split it. Any pushed round OR a legitimate
# "no changes needed" no-op resets the streak, so this only ever fires on an
# unbroken run of failures. Observed on #6723: 7 straight failed rounds (3
# timeouts, 4 gate rejections) over 8 hours, heading for 100.
CONSECUTIVE_FAILURE_CAP: '5'
# Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for
# the failure shape it cannot see: timeouts INTERLEAVED with successful
# rounds. A success resets the consecutive streak, but it does not make the
# next timeout any cheaper — each one burns a full agent budget (~50m of
# runner time) and pushes nothing. Observed on #7929: three timeouts with
# pushed rounds in between, so the consecutive cap never fired and the PR
# kept walking into the same wall; #7846 the same, twice. Counted over the
# current counting window (window-scoped like every other census), so a
# re-arm clears it along with the round counter.
TIMEOUT_WINDOW_CAP: '3'
# Do not claim more issues when too many existing autofix PRs are still open.
MAX_OPEN_AUTOFIX_PRS: '5'
jobs:
# ---------------------------------------------------------------------------
# Router: fork the run into phases by schedule/dispatch input.
# ---------------------------------------------------------------------------
route:
# The issue_comment clause is a cheap expression-level prefilter: the
# overwhelming majority of comments never start a job at all. The real
# gates (exact body match, sender authorization) live in 'Decide phases'.
# Nuance: a body with LEADING whitespace dies here even though the decide
# branch would trim it — fail closed, command must start the comment.
# Both commands are prefiltered here (/takeover toggles the label, /retry
# re-arms a stranded PR); everything else never starts a job.
if: |-
${{ github.repository == 'QwenLM/qwen-code' && (github.event_name != 'issue_comment' || (github.event.issue.pull_request && (startsWith(github.event.comment.body, '@qwen-code /takeover') || startsWith(github.event.comment.body, '@qwen-code /retry')))) && (github.event_name != 'pull_request' || github.event.label.name == 'autofix/takeover') }}
runs-on: 'ubuntu-latest'
timeout-minutes: 5
concurrency:
# Concurrency is keyed by TARGET, not shared and not fully unique:
# • cron ticks share one group (a newer tick supersedes a queued one)
# • review events coalesce PER PR (two reviews on the same PR seconds
# apart route once — the old shared group's one useful side effect,
# kept, without letting events on OTHER PRs cancel this one)
# • issue events coalesce PER issue
# • dispatches are unique per run and are never cancelled
# The old single shared cancel-in-progress group let ANY newer event kill
# pending full scans while route jobs sat queued behind runner backlog —
# observed as hours of scan starvation during review-event storms.
# Five cases: schedule → one shared cron group (newer tick supersedes);
# pull_request_review → per-PR, but ONLY when the review payload
# already looks trusted (the group is entered before any step runs, so
# an arbitrary commenter's review would otherwise cancel a queued
# legitimate route and then die in 'Decide phases' — untrusted payloads
# get a run-unique group and still face the real permission gate
# inside; the association literal mirrors TRUSTED_ASSOC and the login
# mirrors REVIEW_BOT); pull_request label events → per-PR (GitHub only
# lets triage+ apply labels, so the whole event class is trusted —
# in their OWN per-PR group (label-{N}), distinct from the review
# group so a simultaneous review and label toggle on the same PR can
# never cancel each other, and only the takeover label routes at all
# (unrelated labels are filtered at the job gate); issue_comment → its own per-PR command group, but
# ONLY when the commenter's payload association already looks trusted
# (same prefilter pattern as reviews — an untrusted commenter must not
# cancel a maintainer's queued command; untrusted payloads get a
# run-unique group and still face the real permission gate inside), so
# a burst of trusted command comments coalesces to at most two runs
# with latest-intent semantics, never touching review routes;
# issues → per-issue; anything else (dispatch) → unique per run_id,
# never cancelled.
group: >-
${{ github.event_name == 'schedule' && 'qwen-autofix-route-cron' || (github.event_name == 'pull_request_review' && (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association) || github.event.review.user.login == 'qwen-code-ci-bot') && format('qwen-autofix-route-pr-{0}', github.event.pull_request.number)) || (github.event_name == 'pull_request' && github.event.label.name == 'autofix/takeover' && format('qwen-autofix-route-label-{0}', github.event.pull_request.number)) || (github.event_name == 'issue_comment' && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && format('qwen-autofix-route-cmd-{0}', github.event.issue.number)) || (github.event_name == 'issues' && format('qwen-autofix-route-issue-{0}', github.event.issue.number)) || format('qwen-autofix-route-{0}', github.run_id) }}
cancel-in-progress: |-
${{ github.event_name != 'workflow_dispatch' }}
permissions:
contents: 'read'
outputs:
do_issue: '${{ steps.decide.outputs.do_issue }}'
do_review: '${{ steps.decide.outputs.do_review }}'
dry_run: '${{ steps.decide.outputs.dry_run }}'
issue_number: '${{ steps.decide.outputs.issue_number }}'
pr_number: '${{ steps.decide.outputs.pr_number }}'
takeover_ack: '${{ steps.decide.outputs.takeover_ack }}'
ack_pr: '${{ steps.decide.outputs.ack_pr }}'
ack_base: '${{ steps.decide.outputs.ack_base }}'
takeover_cmd: '${{ steps.decide.outputs.takeover_cmd }}'
retry_pr: '${{ steps.decide.outputs.retry_pr }}'
cmd_pr: '${{ steps.decide.outputs.cmd_pr }}'
steps:
- name: 'Decide phases'
id: 'decide'
env:
PHASE: '${{ inputs.phase }}'
FORCED_ISSUE: '${{ inputs.issue_number }}'
FORCED_PR: '${{ inputs.pr_number }}'
DRY_RUN_INPUT: '${{ inputs.dry_run }}'
EVENT_NAME: '${{ github.event_name }}'
GITHUB_TOKEN: '${{ github.token }}'
BUG_LABEL: 'type/bug'
ISSUE_LABEL: '${{ github.event.label.name }}'
ISSUE_LABELS_JSON: '${{ toJSON(github.event.issue.labels.*.name) }}'
ISSUE_NUMBER: '${{ github.event.issue.number }}'
ISSUE_STATE: '${{ github.event.issue.state }}'
READY_FOR_AGENT_LABEL: 'status/ready-for-agent'
AUTOFIX_APPROVED_LABEL: 'autofix/approved'
REPO: '${{ github.repository }}'
SENDER_LOGIN: '${{ github.event.sender.login }}'
ASSIGNEE_LOGIN: '${{ github.event.assignee.login }}'
SCHEDULE: '${{ github.event.schedule }}'
PR_AUTHOR: '${{ github.event.pull_request.user.login }}'
PR_NUMBER_EVENT: '${{ github.event.pull_request.number }}'
PR_HEAD_REPO: '${{ github.event.pull_request.head.repo.full_name }}'
PR_BASE_REF: '${{ github.event.pull_request.base.ref }}'
PR_STATE: '${{ github.event.pull_request.state }}'
EVENT_ACTION: '${{ github.event.action }}'
COMMENT_BODY: '${{ github.event.comment.body }}'
COMMENT_PR_AUTHOR: '${{ github.event.issue.user.login }}'
HAS_PR_URL: '${{ github.event.issue.pull_request.url }}'
run: |-
DO_ISSUE=false
DO_REVIEW=false
TAKEOVER_ACK=''
ACK_BASE=''
TAKEOVER_CMD=''
CMD_PR=''
RETRY_PR=''
DRY_RUN="${DRY_RUN_INPUT:-false}"
sanitize_number() {
local value="${1//$'\r'/}"
value="${value//$'\n'/}"
if [[ "${value}" =~ ^[0-9]+$ ]]; then
printf '%s' "${value}"
elif [[ -n "${value}" ]]; then
echo "::warning::Rejected non-numeric routing input: '${value}'" >&2
fi
}
# workflow_dispatch inputs are user-controlled; keep GITHUB_OUTPUT
# routing values single-line numeric before later jobs consume them.
ROUTE_ISSUE="$(sanitize_number "${FORCED_ISSUE}")"
ROUTE_PR="$(sanitize_number "${FORCED_PR}")"
case "${PHASE}" in
issue) DO_ISSUE=true ;;
review) DO_REVIEW=true ;;
both) DO_ISSUE=true; DO_REVIEW=true ;;
*)
# auto only runs review from scheduled/manual events. Label events
# route below after their trust gates pass.
if [[ "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ]]; then
DO_REVIEW=true
fi
# Scheduled runs scan review PRs first; issue-autofix runs only
# when review-scan reports no target.
if [[ "${EVENT_NAME}" == 'schedule' ]]; then
DO_ISSUE=true
fi
# Real-time review triggers: process the SAME managed set the
# scheduled scan does, so feedback is picked up seconds after the
# review instead of waiting for a schedule GitHub throttles hard
# (the */10 cron actually lands every 40-70min on this repo).
# Reviews must come from trusted senders (collaborators or the
# review bot) so arbitrary commenters cannot force expensive
# review-scan runs. Only pull_request_review:submitted triggers
# (not per-comment events) to avoid redundant runs on
# multi-comment reviews.
if [[ "${EVENT_NAME}" == 'pull_request_review' ]]; then
DO_ISSUE=false
pr_is_managed=false
if [[ "${PR_BASE_REF}" != "main" ]]; then
echo "🧭 review event ignored: PR targets '${PR_BASE_REF}' not 'main'"
elif [[ "${PR_HEAD_REPO}" == "${REPO}" ]]; then
if [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then
pr_is_managed=true
else
echo "🧭 review event ignored: PR author '${PR_AUTHOR}' is not ${AUTOFIX_BOT}"
fi
else
# Fork PR. The scheduled scan already admits these for
# takeover, so real-time pickup applies the SAME admission
# (allow-edits on, and either the bot's own fork or an
# explicit TAKEOVER_LABEL) rather than making the takeover
# PRs — the ones a maintainer is actively waiting on — sit
# through a throttled schedule. This event runs in BASE-repo
# context, and review-address independently re-verifies
# allow-edits, a live write+ author and a matching live head
# repo before it touches the branch, so this only decides
# WHEN that same gated work happens, never whether it may.
fork_meta=''
if fork_meta="$(gh pr view "${PR_NUMBER_EVENT}" --repo "${REPO}" --json labels,maintainerCanModify 2> /dev/null)"; then
fork_allows_edits="$(jq -r '.maintainerCanModify == true' <<< "${fork_meta}")"
fork_has_takeover="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${fork_meta}")"
if [[ "${fork_allows_edits}" != 'true' ]]; then
echo "🧭 review event ignored: fork PR #${PR_NUMBER_EVENT} does not allow maintainer edits"
elif [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" || "${fork_has_takeover}" == 'true' ]]; then
pr_is_managed=true
else
echo "🧭 review event ignored: fork PR #${PR_NUMBER_EVENT} is neither ${AUTOFIX_BOT}'s own fork nor ${TAKEOVER_LABEL}-labeled"
fi
else
echo "🧭 review event ignored: could not read fork PR #${PR_NUMBER_EVENT} metadata"
fi
fi
if [[ "${pr_is_managed}" == 'true' ]]; then
# Verify the reviewer/commenter is trusted (prompt-injection gate).
sender_permission=''
sender_is_trusted=false
if [[ "${SENDER_LOGIN}" == "${REVIEW_BOT}" ]]; then
sender_is_trusted=true
elif [[ -n "${SENDER_LOGIN}" ]]; then
api_error_file="$(mktemp)"
if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then
case "${sender_permission}" in
admin|maintain|write) sender_is_trusted=true ;;
esac
else
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error:-unknown error}"
sender_permission=''
fi
rm -f "${api_error_file}"
fi
if [[ "${sender_is_trusted}" == "true" ]]; then
DO_REVIEW=true
ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")"
echo "🧭 review event on bot PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} (${sender_permission:-review-bot}) → review phase"
else
echo "🧭 review event ignored: sender '${SENDER_LOGIN}' permission='${sender_permission:-none}' is not trusted"
fi
fi
fi
# Comment-command sugar over the labels: TAKEOVER_COMMAND
# applies TAKEOVER_LABEL, 'TAKEOVER_COMMAND stop' removes it —
# nothing else. The label stays the single source of truth:
# engagement and release happen ONLY via the label events
# below; the command also posts acks directly in both
# directions (#7999, #8002). Exact match on the trimmed body (constants, never
# user-input parsing); allowed senders: the PR author (who may
# lack label access) or a write+ collaborator. This immediately
# narrows a previously fully-closed surface reopened under
# maintainer mandate.
if [[ "${EVENT_NAME}" == 'issue_comment' ]]; then
DO_ISSUE=false
DO_REVIEW=false
BODY_TRIMMED="$(printf '%s' "${COMMENT_BODY}" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
CMD=''
[[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND}" ]] && CMD='add'
[[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND} stop" ]] && CMD='remove'
# Re-arm shares the takeover command's authorization exactly:
# both summon bot activity on a managed PR, so inventing a
# second policy would only add surface.
RETRY_REQ=''
[[ "${BODY_TRIMMED}" == "${RETRY_COMMAND}" ]] && RETRY_REQ='true'
if [[ -z "${HAS_PR_URL}" ]]; then
echo "🧭 command ignored: not a PR comment"
elif [[ -z "${CMD}" && -z "${RETRY_REQ}" ]]; then
echo "🧭 command ignored: body is not an exact command"
elif [[ "${ISSUE_STATE}" != 'open' ]]; then
echo "🧭 command ignored: PR is not open"
elif [[ -z "${SENDER_LOGIN}" || "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
echo "🧭 command ignored: sender '${SENDER_LOGIN:-n/a}'"
else
sender_is_authorized=false
sender_permission=''
# Author privilege applies to IN-REPO PRs only: on a fork
# PR the author is an arbitrary external account, and
# accepting them here would let them drive PAT-authored
# writes (even just refusal comments) onto their own PR at
# will. Fork authors without write+ are dropped silently.
CMD_HEAD_REPO="$(gh api "repos/${REPO}/pulls/${ISSUE_NUMBER}" --jq '.head.repo.full_name // ""' 2> /dev/null || echo '')"
if [[ "${SENDER_LOGIN}" == "${COMMENT_PR_AUTHOR}" && "${CMD_HEAD_REPO}" == "${REPO}" ]]; then
# Author privilege is LIVE, not durable: an author removed
# from the repo keeps their PR/head-repo match forever, so
# authorship alone must not keep summoning secret-bearing
# runs. Authors qualify at triage+ (the sugar exists for
# members below write who cannot apply labels).
api_error_file="$(mktemp)"
if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then
case "${sender_permission}" in
admin|maintain|write|triage) sender_is_authorized=true; sender_permission="pr-author/${sender_permission}" ;;
esac
else
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
echo "::warning::Permission API call failed for author ${SENDER_LOGIN}: ${api_error:-unknown error}"
sender_permission=''
fi
rm -f "${api_error_file}"
else
api_error_file="$(mktemp)"
if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then
case "${sender_permission}" in
admin|maintain|write) sender_is_authorized=true ;;
esac
else
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error:-unknown error}"
sender_permission=''
fi
rm -f "${api_error_file}"
fi
if [[ "${sender_is_authorized}" == 'true' ]]; then
if [[ -n "${RETRY_REQ}" ]]; then
RETRY_PR="$(sanitize_number "${ISSUE_NUMBER}")"
echo "🧭 retry command accepted: re-arm PR #${ISSUE_NUMBER} by ${SENDER_LOGIN} (${sender_permission})"
else
TAKEOVER_CMD="${CMD}"
CMD_PR="$(sanitize_number "${ISSUE_NUMBER}")"
echo "🧭 takeover command accepted: ${CMD} ${TAKEOVER_LABEL} on PR #${ISSUE_NUMBER} by ${SENDER_LOGIN} (${sender_permission})"
fi
else
echo "🧭 command ignored: sender '${SENDER_LOGIN}' permission='${sender_permission:-none}' is not the PR author or write+"
fi
fi
fi
# Label-driven takeover: TAKEOVER_LABEL applied to an eligible
# PR summons the loop onto it (human-authored included);
# removing it releases the PR — future engagement stops, and an
# in-flight round, if any, completes its bounded work (matrix
# runs are shared across PRs, so cancelling one is not possible
# without collateral damage). ISSUE_LABEL carries the label
# name for pull_request events too (same payload field).
if [[ "${EVENT_NAME}" == 'pull_request' ]]; then
DO_ISSUE=false
if [[ "${ISSUE_LABEL}" != "${TAKEOVER_LABEL}" ]]; then
echo "🧭 pull_request ${EVENT_ACTION} ignored: label '${ISSUE_LABEL:-n/a}' is not ${TAKEOVER_LABEL}"
elif [[ "${EVENT_ACTION}" == 'labeled' ]]; then
if [[ "${PR_HEAD_REPO}" != "${REPO}" ]]; then
# Fork pull_request events carry NO secrets, so neither
# the immediate scan nor the ack job can run from this
# event. The label still counts: the next scheduled scan
# (repo context, ≤10m) admits fork takeover PRs whose
# author holds write+ and whose PR allows maintainer
# edits, and posts the engage ack on first pickup.
echo "🧭 fork takeover noted for #${PR_NUMBER_EVENT} — the next scheduled scan engages (author write+ and allow-edits required)"
elif [[ "${PR_STATE}" != 'open' ]]; then
echo "🧭 takeover ignored: PR state '${PR_STATE:-unknown}' is not open"
elif [[ "${PR_BASE_REF}" != 'main' ]]; then
# Refuse OUT LOUD. Staying silent here made a labelled
# stacked PR indistinguishable from a managed one: the
# label stuck, the route run went green, and the only
# trace was this log line — so the PR sat unmanaged for
# hours with nobody able to tell without reading the job
# log. The ack job posts the explanation instead.
TAKEOVER_ACK='base-refused'
ACK_BASE="${PR_BASE_REF}"
echo "🧭 takeover ignored: PR targets '${PR_BASE_REF}' not 'main'"
else
DO_REVIEW=true
ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")"
if [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
# The bot only applies this label from takeover-command,
# which posts the engage ack ITSELF: the labeled event
# has been observed to simply not fire (#7999 — the
# author read the silence as failure and removed the
# label; #8002), so the user-visible ack must not
# depend on this round-trip. Suppress only the ack —
# the immediate scan is this event's real work and
# still routes.
echo "🧭 engage ack skipped: label applied by ${AUTOFIX_BOT} — the command path already acked"
else
TAKEOVER_ACK='engaged'
fi
echo "🧭 ${TAKEOVER_LABEL} applied by ${SENDER_LOGIN} on PR #${PR_NUMBER_EVENT} → review phase (takeover)"
fi
elif [[ "${EVENT_ACTION}" == 'unlabeled' ]]; then
# Mirror the labeled-path guards: a fork, closed, or
# non-main PR was never engaged, so a release ack would
# announce a disengagement that never existed.
if [[ "${PR_STATE}" != 'open' || "${PR_BASE_REF}" != 'main' ]]; then
echo "🧭 takeover release ignored: PR state '${PR_STATE:-unknown}' base '${PR_BASE_REF:-unknown}' was never engaged"
elif [[ "${PR_HEAD_REPO}" != "${REPO}" ]]; then
# Fork pull_request events carry no secrets: emitting the
# ack here would start takeover-ack with an empty PAT and
# fail its identity check — a red run for a label that
# never engaged anything. Log and stop.
echo "🧭 takeover release ignored: PR is a fork (${PR_HEAD_REPO} != ${REPO})"
elif [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
# Mirror of the labeled-path suppression: the bot only
# removes this label from takeover-command, which posts
# the release ack itself — acking here too would
# double-post on every command-driven stop.
echo "🧭 release ack skipped: label removed by ${AUTOFIX_BOT} — the command path already acked"
else
TAKEOVER_ACK='released'
echo "🧭 ${TAKEOVER_LABEL} removed from PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} → released"
fi
fi
fi
if [[ "${EVENT_NAME}" == 'issues' ]]; then
DO_REVIEW=false
label_is_trigger=false
[[ "${ISSUE_LABEL}" == "${READY_FOR_AGENT_LABEL}" || "${ISSUE_LABEL}" == "${BUG_LABEL}" || "${ISSUE_LABEL}" == "${AUTOFIX_APPROVED_LABEL}" ]] && label_is_trigger=true
[[ "${ASSIGNEE_LOGIN}" == "${AUTOFIX_BOT}" ]] && label_is_trigger=true
sender_permission=''
sender_is_trusted=false
if [[ -n "${SENDER_LOGIN}" ]]; then
if ! sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>&1)"; then
api_error="${sender_permission}"
sender_permission=''
api_error="${api_error//$'\r'/ }"
api_error="${api_error//$'\n'/ }"
echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error}"
fi
[[ "${sender_permission}" == 'write' || "${sender_permission}" == 'maintain' || "${sender_permission}" == 'admin' ]] && sender_is_trusted=true
fi
if [[ "${label_is_trigger}" != 'true' ]]; then
# A non-trigger label (e.g. scope/*, priority/*) may arrive
# after the trigger labels and cancel their runs via per-issue
# concurrency. If the issue already carries both required
# labels and the current sender is trusted, proceed anyway.
_late_ready="$(jq -r --arg l "${READY_FOR_AGENT_LABEL}" 'index($l) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
_late_approved="$(jq -r --arg l "${AUTOFIX_APPROVED_LABEL}" 'index($l) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
if [[ "${ISSUE_STATE}" == 'open' && "${_late_ready}" == 'true' && "${_late_approved}" == 'true' && "${sender_is_trusted}" == 'true' ]]; then
echo "🧭 non-trigger label '${ISSUE_LABEL:-n/a}' but issue #${ISSUE_NUMBER} already approved+ready → issue phase"
DO_ISSUE=true
else
echo "🧭 issue event ignored: trigger_label=false label='${ISSUE_LABEL:-n/a}' issue='#${ISSUE_NUMBER:-n/a}'"
fi
else
issue_is_bug="$(jq -r --arg label "${BUG_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
issue_is_ready="$(jq -r --arg label "${READY_FOR_AGENT_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
issue_is_approved="$(jq -r --arg label "${AUTOFIX_APPROVED_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
if [[ "${ISSUE_STATE}" == 'open' && "${issue_is_ready}" == 'true' && "${issue_is_approved}" == 'true' && "${label_is_trigger}" == 'true' && "${sender_is_trusted}" == 'true' ]]; then
DO_ISSUE=true
else
if [[ "${ISSUE_STATE}" == 'open' && "${label_is_trigger}" == 'true' && "${sender_is_trusted}" == 'true' && "${issue_is_ready}" != "${issue_is_approved}" ]]; then
echo "::notice::Issue #${ISSUE_NUMBER:-n/a} needs both ${READY_FOR_AGENT_LABEL} and ${AUTOFIX_APPROVED_LABEL} before autofix can run."
fi
echo "🧭 issue event ignored: state_open=$([[ "${ISSUE_STATE}" == 'open' ]] && echo true || echo false) bug=${issue_is_bug} ready=${issue_is_ready} approved=${issue_is_approved} trigger_label=${label_is_trigger} sender_permission='${sender_permission:-none}' sender_trusted=${sender_is_trusted} label='${ISSUE_LABEL:-n/a}' issue='#${ISSUE_NUMBER:-n/a}'"
fi
fi
fi
;;
esac
# Forcing a specific issue/PR implies running that phase only for
# explicit manual dispatch. Event payload numbers still flow to the
# phase jobs after routing, but must not bypass the label/schedule gates.
# Explicit phases (issue/review/both) take precedence over forced
# issue/PR overrides — only apply forced routing in auto/default mode.
if [[ "${EVENT_NAME}" == 'workflow_dispatch' && ( -z "${PHASE}" || "${PHASE}" == 'auto' ) ]]; then
[[ -n "${ROUTE_ISSUE}" && -z "${ROUTE_PR}" ]] && DO_ISSUE=true && DO_REVIEW=false
[[ -n "${ROUTE_PR}" && -z "${ROUTE_ISSUE}" ]] && DO_ISSUE=false && DO_REVIEW=true
[[ -n "${ROUTE_ISSUE}" && -n "${ROUTE_PR}" ]] && DO_ISSUE=true && DO_REVIEW=true
fi
echo "do_issue=${DO_ISSUE}" >> "${GITHUB_OUTPUT}"
echo "do_review=${DO_REVIEW}" >> "${GITHUB_OUTPUT}"
echo "dry_run=${DRY_RUN}" >> "${GITHUB_OUTPUT}"
echo "issue_number=${ROUTE_ISSUE}" >> "${GITHUB_OUTPUT}"
echo "pr_number=${ROUTE_PR}" >> "${GITHUB_OUTPUT}"
echo "takeover_ack=${TAKEOVER_ACK}" >> "${GITHUB_OUTPUT}"
echo "ack_pr=$(sanitize_number "${PR_NUMBER_EVENT}")" >> "${GITHUB_OUTPUT}"
echo "ack_base=${ACK_BASE}" >> "${GITHUB_OUTPUT}"
echo "takeover_cmd=${TAKEOVER_CMD}" >> "${GITHUB_OUTPUT}"
echo "retry_pr=${RETRY_PR}" >> "${GITHUB_OUTPUT}"
echo "cmd_pr=${CMD_PR}" >> "${GITHUB_OUTPUT}"
echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' pr='#${PR_NUMBER_EVENT:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}"
# ===========================================================================
# ISSUE PHASE — locate one maintainer-ready issue, fix it, open a PR.
# ===========================================================================
issue-autofix:
needs: ['route', 'review-scan']
if: |-
${{
always() &&
needs.route.outputs.do_issue == 'true' &&
(github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true'))
}}
# Secret-bearing and executes agent-driven code, but the agent runs inside
# the docker sandbox image and only ever writes a new branch as the
# dev-bot — it never executes a foreign author's code. Forks of this repo
# (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to hosted. On
# pull_request / pull_request_review events the ECS route additionally
# needs a same-repo head or a write+ author (ci.yml's pick_runner form);
# the other triggers skip that clause and rely on their own gates
# instead: issues / schedule require autofix/approved plus
# status/ready-for-agent on the issue, and workflow_dispatch rides the
# actor's own write access. Docker availability on this pool is proven
# in-repo by qwen-triage's container jobs, which run on the same
# runner labels.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 180
# route.issue_number is only set for forced dispatches; label events carry
# the issue in the payload, and scan-and-pick runs (cron, unforced
# dispatch) share one 'scheduled' group. The old github.run_id fallback
# made every scan-and-pick run its own group, so two overlapping scans
# (cron fires every 40-70min, this job runs up to 180) could double-claim
# the same issue — the claim recheck runs after assess and only narrows
# the race to the short gap between the recheck and the claim's label
# write; it does not close it. Queued (never cancelled) so the newest
# pending tick still runs after a long scan if targets remain;
# intermediate ticks are superseded, which is fine because each run
# rescans from scratch.
#
# GitHub evaluates concurrency before the job `if`, but after `needs`, so
# the group is gated on the same runnability predicate as the `if` above,
# plus a dry-run exclusion: runs whose issue phase will not execute
# (do_issue=false takeover, review, and command events; label events
# failing the decide gates; scheduled ticks whose review-scan still has
# targets) and dry runs (if-runnable, but their Claim/Publish steps are
# gated off) get a run-unique group instead — a run that never claims
# entering a target-keyed group would replace the single pending run
# there and silently cancel it. Same precedent as qwen-triage.yml's
# triage/tmux jobs.
concurrency:
group: >-
${{ needs.route.outputs.do_issue == 'true' && needs.route.outputs.dry_run != 'true' && (github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')) && format('qwen-autofix-issue-{0}', needs.route.outputs.issue_number || github.event.issue.number || 'scheduled') || format('qwen-autofix-issue-run-{0}', github.run_id) }}
cancel-in-progress: false
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
# Per-run private dir: this pool carries many registrations sharing one
# OS /tmp, and issue-phase runs never serialize against each other, so
# a fixed path let concurrent runs clobber each other's decision files.
WORKDIR: '/tmp/autofix-${{ github.run_id }}'
EVENT_NAME: '${{ github.event_name }}'
READY_FOR_AGENT_LABEL: 'status/ready-for-agent'
AUTOFIX_APPROVED_LABEL: 'autofix/approved'
AUTOFIX_ISSUE_EXCLUDES: 'no:assignee -linked:pr -label:autofix/skip -label:autofix/in-progress -label:status/need-information -label:status/need-retesting sort:created-desc'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Self-hosted runners keep the workspace between runs, and other pool
# jobs execute human-authored code as the runner user, so a prior job
# can plant git exec knobs (core.fsmonitor, filter.*.smudge,
# diff.external, includeIf, hooks) in the local config that would fire
# inside THIS job's PAT-bearing git steps. It keeps
# a known-safe allowlist and unsets everything else, hardened against
# the worktree-config and global-hooksPath bypasses verified in
# qwen-triage on this pool. No-op on a fresh hosted runner.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: 'Reset autofix workspace'
run: |-
rm -rf "${WORKDIR}"
# 0700: the dir holds agent transcripts and decision files; the
# sandbox container runs as this same user, so the tighter mode
# costs the job nothing. umask at creation, not mkdir-then-chmod —
# the chmod form leaves a world-readable window on this shared /tmp.
(umask 077; mkdir -p "${WORKDIR}")
# Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard
# runner kill skips the always() teardown and run_id never repeats,
# so nothing else ever reclaims them.
find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true
# The reused workspace's .git accumulates unreferenced objects
# across fetch runs on this persistent pool; prune them.
git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true
# Self-hosted runners keep the workspace's .git across runs, so a
# failed earlier attempt's local branch survives here: the agent's
# branch create then dies "branch already exists", or an adaptation
# checks out the stale line and pushes the failed attempt's commits
# into the new PR. Drop them deterministically (refs survive
# actions/checkout's untracked-file clean).
- name: 'Drop stale autofix branches'
run: |-
# Detach first: `git branch -D` refuses the currently checked-out
# branch, so a stale autofix branch holding HEAD would otherwise
# silently survive the sweep (actions/checkout normally leaves
# HEAD on the default branch; this makes the sweep unconditional).
git checkout --detach 2>/dev/null || true
git for-each-ref --format='%(refname:short)' "refs/heads/${BRANCH_PREFIX}*" \
| xargs -r -n 1 git branch -D 2>/dev/null || true
# Same staging as the review-address job: the verify gate always runs the
# trusted checkout's copy of the schema gate, never a working-tree copy.
- name: 'Stage trusted schema gate'
run: |-
cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh"
cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh"
cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh"
- name: 'Check bot credentials'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
run: |-
if [[ -z "${GITHUB_TOKEN}" ]]; then
echo '::error::CI_DEV_BOT_PAT is required to run the issue autofix job.'
exit 1
fi
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}."
exit 1
fi
- name: 'Check runner environment'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
RUNNER_NAME: '${{ runner.name }}'
run: |-
case "${RUNNER_ENVIRONMENT}" in
github-hosted|self-hosted) ;;
*)
echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}."
exit 1
;;
esac
# The label routing pins ecs-qwen, but a mis-labelled registration
# must not silently claim a PAT-bearing 300-minute job — assert the
# pool by name on the self-hosted branch too.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*) ;;
*)
echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here."
exit 1
;;
esac
fi
# Capability preflight for the persistent pool: this job's agent
# runs inside the docker sandbox, and a missing daemon otherwise
# surfaces only at 'Resolve sandbox image' — after npm ci/build
# has already burned tens of minutes. Fail in seconds instead.
# Hosted runners ship docker; the ECS pool's docker is proven by
# qwen-triage's container jobs on the same labels.
if ! docker info > /dev/null 2>&1; then
echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start."
exit 1
fi
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install tmux'
run: |-
if command -v tmux > /dev/null 2>&1; then
tmux -V
elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
# sudo -n: a host without passwordless sudo must fail fast with a
# clear message, not die on a password prompt (the pr-review pool
# steps make the same assumption with `sudo -n ... || ::warning`).
sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || {
echo '::error::tmux is required on the autofix runner and passwordless install failed.'
exit 1
}
else
echo '::error::tmux is required on the autofix runner.'
exit 1
fi
# The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below
# are duplicated in build-cli and review-address; the workflow
# contract tests pin every copy in lockstep — edit them together.
- name: 'Install dependencies and build'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
break
fi
if [[ "${attempt}" == "3" ]]; then
exit 1
fi
sleep $((attempt * 15))
done
git config core.hooksPath .husky
npm run build
npm run bundle
- name: 'Prepare Qwen Code CLI'
run: |-
qwen_version="$(node -p "require('./package.json').version")"
echo "Using checked-out Qwen Code bundle ${qwen_version}"
qwen_bin="${RUNNER_TEMP}/qwen-bin"
mkdir -p "${qwen_bin}"
cat > "${qwen_bin}/qwen" <<'EOF'
#!/usr/bin/env bash
exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@"
EOF
chmod +x "${qwen_bin}/qwen"
echo "${qwen_bin}" >> "${GITHUB_PATH}"
PATH="${qwen_bin}:${PATH}"
qwen --version
- name: 'Find candidate issues'
id: 'scan'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
# Must resolve to the same issue as this job's concurrency group
# expression; a test pins the two equal.
FORCED_ISSUE: '${{ needs.route.outputs.issue_number || github.event.issue.number }}'
run: |-
mkdir -p "${WORKDIR}"
OPEN_AUTOFIX_PR_COUNT=0
if [[ -n "${FORCED_ISSUE}" ]]; then
echo "🎯 Forced issue #${FORCED_ISSUE}"
forced_issue_json="${WORKDIR}/forced-issue.json"
gh issue view "${FORCED_ISSUE}" --repo "${REPO}" \
--json number,title,body,labels,createdAt,url,state \
> "${forced_issue_json}"
if jq -e \
'(.labels // []) | map(.name) | any(. == "autofix/skip" or . == "autofix/in-progress")' \
"${forced_issue_json}" > /dev/null; then
echo "⏭️ Forced issue #${FORCED_ISSUE} has an autofix exclusion label; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
elif [[ "$(jq -r '.state // ""' "${forced_issue_json}")" != 'OPEN' ]]; then
echo "⏭️ Forced issue #${FORCED_ISSUE} is not open; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
# workflow_dispatch is a maintainer-initiated escape hatch, so it
# intentionally bypasses the label gates that protect event/cron
# paths from issue-content prompt injection.
elif [[ "${EVENT_NAME}" != 'workflow_dispatch' ]] && ! jq -e --arg ready "${READY_FOR_AGENT_LABEL}" \
'(.labels // []) | map(.name) as $labels | ($labels | index($ready))' \
"${forced_issue_json}" > /dev/null; then
echo "⏭️ Forced issue #${FORCED_ISSUE} is missing ${READY_FOR_AGENT_LABEL}; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
elif [[ "${EVENT_NAME}" != 'workflow_dispatch' ]] && ! jq -e --arg approved "${AUTOFIX_APPROVED_LABEL}" \
'(.labels // []) | map(.name) as $labels | ($labels | index($approved))' \
"${forced_issue_json}" > /dev/null; then
echo "⏭️ Forced issue #${FORCED_ISSUE} is missing ${AUTOFIX_APPROVED_LABEL}; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '[. + {autofixTier: 0}]' "${forced_issue_json}" > "${WORKDIR}/candidates.json"; then
echo "::warning::Forced issue #${FORCED_ISSUE} processing failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
fi