-
-
Notifications
You must be signed in to change notification settings - Fork 105
1298 lines (1234 loc) · 81 KB
/
Copy pathbot-reply.yml
File metadata and controls
1298 lines (1234 loc) · 81 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: Bot Reply on Mention
# EXECUTES FROM: THE DEFAULT BRANCH, always (dispatched by the agent router
# on the default branch; issue_comment-family triggers are default-branch
# by GitHub rule). Platform updates land on main - see
# docs/workflows/overview.md for the execution map and update doctrine.
on:
# Anti-phantom: an explicit never-matching push trigger. Dispatch-only
# workflows otherwise get dead push-event runs (red X, 0 jobs) whenever
# a push modifies the workflow file (actions/runner#4001 residue, live-
# observed repeatedly). With this declared, push-event evaluation finds
# no matching ref and creates NO run at all. Semantics unchanged.
push:
branches-ignore: ["**"]
# Routed by agent-router.yml (the single issue_comment entrypoint). The
# router passes the triggering comment's id; this workflow re-fetches the
# comment, its author, and association from the GitHub API — identity
# signals come from GitHub, not from the dispatch payload.
workflow_dispatch:
inputs:
# required: false at the SCHEMA level, enforced at RUNTIME instead
# (first step): GitHub platform bug (actions/runner#4001) gives
# dispatch-only workflows phantom push-event runs that fail input
# validation (red X, 0s, no jobs) when inputs are schema-required.
# Optional + the event guard on the job turn phantoms into clean
# skips; the runtime check keeps real dispatches honest.
commentId:
description: 'Id of the comment that triggered the reply'
required: false
type: string
threadNumber:
description: 'Issue/PR/discussion number the comment belongs to (concurrency key: same-thread replies serialize)'
required: false
type: string
# threadType: issue (default) | discussion | discussion-new.
# discussion = mention inside a discussion COMMENT (commentId = the
# discussion comment's databaseId; dispatched by the router's
# discussion job, or by mention-poller for foreign discussions).
# discussion-new = mention in a NEW discussion's BODY (no commentId;
# the discussion itself is the trigger). Discussions have no REST
# endpoints — every fetch/post in that mode is GraphQL.
threadType:
description: 'issue | discussion | discussion-new'
required: false
default: ''
type: string
# Serialize same-thread agent runs: a second mention on a thread waits for the
# in-flight run to finish instead of racing a parallel agent session against
# it. Different threads are unaffected (distinct groups). Discussion numbers
# are a SEPARATE counter from issues (discussion #42 and issue #42 can
# coexist) — discussion runs carry a disc- prefix so they never serialize
# against an unrelated issue thread. KNOWN CAVEAT:
# GitHub keeps only ONE pending run per concurrency group - a THIRD rapid
# mention can supersede a still-queued second. Accepted trade-off: the
# superseded mention's content is still in the thread history and the next
# run on that thread sees it.
concurrency:
group: bot-reply-${{ (inputs.threadType == 'discussion' || inputs.threadType == 'discussion-new') && format('disc-{0}', inputs.threadNumber) || inputs.threadNumber || github.run_id }}
cancel-in-progress: false
jobs:
continuous-reply:
# The bot-loop guard and mention detection live in the router; this
# resolve step below re-validates both from the API (defense in depth:
# manual dispatch of a bot-authored or mention-less comment no-ops).
# Event guard: phantom push-event runs (see inputs comment) skip the
# whole job instead of running with empty inputs.
# AGENT_PAUSED variable: kill switch - paused runs skip entirely
# (visible gray skip); the stub and compliance-gate keep the pending
# status posted so merges stay blocked while paused.
# Per-part pause (job-level, from the AGENT_PAUSED_PARTS_JSON variable):
# true = this part skips visibly (gray); missing key/variable = runs;
# malformed JSON = the run FAILS LOUDLY at expression evaluation - a
# broken kill switch must be visible. AGENT_PAUSED stays the global kill.
if: github.event_name == 'workflow_dispatch' && vars.AGENT_PAUSED != 'true' && !fromJSON(vars.AGENT_PAUSED_PARTS_JSON || '{}')['bot-reply']
runs-on: ubuntu-latest
# Least privilege for the built-in GITHUB_TOKEN: it is only used by the
# initial actions/checkout. All bot operations (reactions, comments, pushes)
# authenticate with the GitHub App installation token from bot-setup, whose
# scopes come from the App installation settings, not this block.
permissions:
contents: read
env:
# ─── EDITABLE KNOBS ──────────────────────────────────────────────────
# Everything a fork tunes in THIS file lives near this block (the rest
# is machinery). Full reference: docs/configuration.md and
# docs/workflows/bot-reply.md.
# Diff split threshold (bytes) - diffs larger than this are SPLIT into
# navigable parts + an index (split-diff.sh); never truncated.
# 1000000 = ~1MB per part.
DIFF_SPLIT_BYTES: '1000000'
# PR-TARGET context only ("is the base a maintained branch?" for the
# trust-context line) — NOT the scrub anchor. Scrub trust is SPLIT:
# .github taint anchors to MAIN alone; auto-load content trusts
# main+dev (see scrub-workspace.sh TRUST CONFIGURATION).
MAINTAINED_BASE_BRANCHES: 'main dev'
THREAD_NUMBER: ${{ inputs.threadNumber }}
# Discussion mode (see threadType input): empty = issue/PR thread.
THREAD_TYPE: ${{ inputs.threadType || '' }}
# Identities of THIS agent only (case-insensitive comparisons).
# NOTE: bare "mirrobot" is the NAME, not an identity - a human user
# named mirrobot is NOT the agent (name vs identity, like two people
# sharing a name). Mention-routing still accepts @mirrobot.
# Identity + triggers (resolved at runtime by bot-config.sh from these
# inputs, the repo variables, and account-mode /user detection):
# identities = BOT_IDENTITIES variable (this agent + its [bot] app
# form) (+) the /user-detected account login; fallback only
# when both are absent.
# trigger stems = BOT_TRIGGERS variable, else identity-derived
# (@stem, /stem-review, /stem-check), else the mirrobot words.
BOT_IDENTITIES_INPUT: ${{ vars.BOT_IDENTITIES || '' }}
BOT_TRIGGERS_INPUT: ${{ vars.BOT_TRIGGERS || '' }}
# NOTE: no BOT_NAMES_JSON declaration here, ever. It is normalized to
# the JSON array once by the unconditional early step below and
# re-exported authoritatively by bot-config.sh; a job/step-level
# env: declaration would SHADOW those GITHUB_ENV exports and leak the
# flat variable format into every jq --argjson consumer.
AGENT_PAUSED_PARTS_JSON: ${{ vars.AGENT_PAUSED_PARTS_JSON || '' }}
# Noise filtering for thread context (see fetch-pr-discussion.sh):
# CONTEXT_IGNORE_AUTHORS - comma logins dropped outright (repo variable)
# CONTEXT_FILTER_PATTERNS_JSON - JSON array of body regexes dropping
# matching posts; unset = baked AI-reviewer noise defaults
CONTEXT_IGNORE_AUTHORS: ${{ vars.CONTEXT_IGNORE_AUTHORS || '' }}
CONTEXT_FILTER_PATTERNS_JSON: ${{ vars.CONTEXT_FILTER_PATTERNS_JSON || '' }}
# Context budget (CONTEXT_LIMITS_JSON variable, per-key defaults in
# fetch-pr-discussion.sh): comments/reviews/own-reviews/
# threads-per-review/thread-comments/orphan-threads/
# orphan-thread-comments. Newest-first, filter-before-cap.
CONTEXT_LIMITS_JSON: ${{ vars.CONTEXT_LIMITS_JSON || '' }}
PREVIOUS_BOT_REVIEWS_COUNT: ${{ vars.PREVIOUS_BOT_REVIEWS_COUNT || '1' }}
# secrets.* is not evaluable in step-level if: conditionals — the
# push-path validation of dispatch-only workflows (actions/runner#4001
# phantom runs) rejects the file with "Unrecognized named-value:
# 'secrets'". Deriving the boolean here (job env, where secrets IS
# valid) and branching on env.X in step ifs is the sanctioned pattern.
# NOTE: a later step writing ACCOUNT_TOKEN_SET to $GITHUB_ENV would
# override this — do not do that.
ACCOUNT_TOKEN_SET: ${{ secrets.ACCOUNT_GH_TOKEN != '' }}
steps:
# Kill-switch shape validation: job-level if-expressions cannot
# type-check (fromJSON of a VALID-but-wrong-shape value — e.g. `[]`,
# `3` — silently evaluates the part as not-paused). This step makes
# a broken AGENT_PAUSED_PARTS_JSON fail LOUDLY, matching the
# router/stub gates: a kill switch you cannot see working is a
# kill switch you cannot trust.
- name: Validate pause config
env:
PARTS_JSON: ${{ vars.AGENT_PAUSED_PARTS_JSON || '{}' }}
run: |
printf '%s' "$PARTS_JSON" | jq -e 'type == "object"' >/dev/null || {
echo "::error::AGENT_PAUSED_PARTS_JSON must be a JSON object like {\"pr-review\": true} — got a different shape. Refusing to run with a broken kill switch (fix or delete the variable)."
exit 1
}
# Runtime enforcement of the dispatch contract (schema-required is
# impossible - see the inputs comment for the platform bug).
- name: Validate dispatch inputs
env:
COMMENT_ID_INPUT: ${{ inputs.commentId }}
THREAD_NUMBER_INPUT: ${{ inputs.threadNumber }}
THREAD_TYPE_INPUT: ${{ inputs.threadType || '' }}
run: |
# threadNumber: always required. commentId: required only on the
# comment path — discussion dispatches (discussion-new has no
# comment) resolve their trigger content by re-fetch instead.
# Guest sessions dispatch bot-reply-guest.yml (their own workflow).
if [ -z "$THREAD_NUMBER_INPUT" ]; then
echo "::error::threadNumber dispatch input is required (got empty)"
exit 1
fi
if [ -z "$COMMENT_ID_INPUT" ] && [ -z "$THREAD_TYPE_INPUT" ]; then
echo "::error::commentId dispatch input is required on the home comment path (got empty)"
exit 1
fi
# Normalize the identity list ONCE, unconditionally, before any
# consumer. The old in-step normalizers only ran on some dispatch
# paths - commentId-less dispatches (guest relays, discussion-new)
# hit every jq --argjson consumer with the raw flat value
# ("jq: invalid JSON text passed to --argjson"). Unconditional +
# early + no shadowing env: declaration = the array is valid for
# every code path. bot-config.sh later re-exports the authoritative
# array (with the /user-detected account login merged in).
- name: Normalize identity list
env:
BOT_IDENTITIES_INPUT: ${{ vars.BOT_IDENTITIES || '' }}
run: |
raw="${BOT_IDENTITIES_INPUT:-mirrobot-agent, mirrobot-agent[bot]}"
# Lowercased by design: consumers lowercase the author side and
# compare against this array (ascii_downcase as $a | $bots |
# index($a)); a display-case array matches nothing (live-caught:
# FIRST misclassification, empty review-memory blocks, own
# reviews polluting thread context as "other reviewers").
case "$raw" in
'['*) printf 'BOT_NAMES_JSON=%s\n' "$(printf '%s' "$raw" | jq -sc 'map(ascii_downcase)')" >> "$GITHUB_ENV" ;;
*) printf 'BOT_NAMES_JSON=%s\n' "$(printf '%s' "$raw" | tr ',;' '\n\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | awk 'NF' | jq -R . | jq -sc 'map(ascii_downcase)')" >> "$GITHUB_ENV" ;;
esac
# Sparse default-branch checkout for the SHARED routing script, so the
# re-validation below runs the exact same decision logic as the router
# (single source of truth: .github/scripts/route-comment.sh). The
# workflow_dispatch event is default-branch-guaranteed; no PR ref here.
- name: Checkout routing script
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
persist-credentials: false
# ========================================================================
# COMMENT RESOLVE STEP (dispatch path)
# ========================================================================
# Re-fetches the triggering comment from the GitHub API by id and
# re-validates it (defense in depth): bot-authored comments never
# proceed, and the mention must appear in actual content (not inside
# quotes or code fences) per the shared route-comment.sh logic. On
# failure, all subsequent steps are skipped.
# The job token (contents: read) suffices: this is a public-repo read.
- name: Resolve and validate comment
id: validate
env:
COMMENT_ID_INPUT: ${{ inputs.commentId }}
THREAD_NUM_INPUT: ${{ inputs.threadNumber }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Identity + trigger stems FIRST (route-comment.sh below falls back
# to stock trigger words when BOT_TRIGGER_STEMS is unset - a custom
# BOT_TRIGGERS variable would fail re-validation and silently skip).
eval "$(bash .github/scripts/bot-config.sh --export)"
# ==================================================================
# REST read endpoints (probe-verified 2026-09-09: the notification
# subject URL is REST-shaped but dead; every field below is
# GraphQL-verified live, including Discussion.authorAssociation;
# isAnswered returns null for non-answerable categories).
# ==================================================================
if [ -n "${THREAD_TYPE:-}" ]; then
case "$THREAD_TYPE" in
discussion|discussion-new) ;;
*) echo "::error::Unknown threadType '${THREAD_TYPE}'."; echo "should_proceed=false" >> "$GITHUB_OUTPUT"; exit 0 ;;
esac
REPO_SLUG="$GITHUB_REPOSITORY"
D_OWNER="${REPO_SLUG%%/*}"; D_NAME="${REPO_SLUG##*/}"
# NOTE: never write "${CONTEXT_LIMITS_JSON:-{}}" — the first }
# TERMINATES the expansion, appending a literal } to a set value
# (live-caught: jq Unmatched '}' on the seeded variable).
# Discussion budget mirrors the PR-review thread model: X newest
# top-level comment-threads, N newest replies within each, both
# windows filtered BEFORE capping (dropped content never
# consumes budget), with not-shown counts surfaced so the agent
# can retrieve more when it matters.
CLJ="${CONTEXT_LIMITS_JSON:-}"
[ -z "$CLJ" ] && CLJ='{}'
LIM_DT=$(printf '%s' "$CLJ" | jq -r '."discussion-threads" // 40')
LIM_DR=$(printf '%s' "$CLJ" | jq -r '."discussion-replies" // 30')
LIM_BC=$(printf '%s' "$CLJ" | jq -r '."body-chars" // 4000')
# Overfill 3x for filter-before-cap, clamped to GraphQL's 100 max.
TCOUNT=$LIM_DT; [ $((TCOUNT * 3)) -le 100 ] && TCOUNT=$((LIM_DT * 3)) || TCOUNT=100
RCOUNT=$LIM_DR; [ $((RCOUNT * 3)) -le 100 ] && RCOUNT=$((LIM_DR * 3)) || RCOUNT=100
if ! disc_json=$(gh api graphql \
-f owner="$D_OWNER" -f name="$D_NAME" -F n="$THREAD_NUM_INPUT" -F tcount="$TCOUNT" -F rcount="$RCOUNT" \
-f query='query($owner:String!,$name:String!,$n:Int!,$tcount:Int!,$rcount:Int!) { repository(owner:$owner,name:$name) { discussion(number:$n) { id number title body createdAt author { login } authorAssociation category { name isAnswerable } isAnswered answer { id } comments(last:$tcount) { totalCount nodes { id databaseId author { login } authorAssociation isMinimized createdAt body replies(last:$rcount) { totalCount nodes { id databaseId author { login } authorAssociation isMinimized createdAt body } } } } } } }' \
--jq '.data.repository.discussion' 2>/dev/null) || [ "$disc_json" = "null" ]; then
echo "::notice::Discussion ${REPO_SLUG}#${THREAD_NUM_INPUT} not readable; nothing to do."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"; exit 0
fi
# BOT_NAMES_JSON is already the normalized JSON array
# (unconditional early step; bot-config.sh re-exports the
# authoritative array later).
# --- resolve the trigger --------------------------------------
trig_body=""; trig_author=""; trig_assoc="NONE"; trig_node=""; trig_anchor=""; trig_is_comment=0
if [ "$THREAD_TYPE" = "discussion" ] && [ -n "${COMMENT_ID_INPUT:-}" ]; then
trig=$(printf '%s' "$disc_json" | jq -r --arg id "$COMMENT_ID_INPUT" '
[((.comments.nodes // [])[] | ({db: (.databaseId|tostring), node: .id, anchor: .id, author: (.author.login // "?"), assoc: (.authorAssociation // "NONE"), body: (.body // ""), ok: true}))
, ((.comments.nodes // [])[] | . as $c | ((.replies.nodes // [])[]) | {db: (.databaseId|tostring), node: .id, anchor: $c.id, author: (.author.login // "?"), assoc: (.authorAssociation // "NONE"), body: (.body // ""), ok: true})]
| .[] | select(.db == $id)')
if [ -z "$trig" ] || [ "$trig" = "null" ]; then
echo "::notice::Trigger comment ${COMMENT_ID_INPUT} not found in the discussion window (older than the last ${LIM_DT} comment-threads?); declining rather than acting on partial context."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"; exit 0
fi
trig_body=$(printf '%s' "$trig" | jq -r '.body')
trig_author=$(printf '%s' "$trig" | jq -r '.author')
trig_assoc=$(printf '%s' "$trig" | jq -r '.assoc')
trig_node=$(printf '%s' "$trig" | jq -r '.node')
# Anchor = OWNING TOP-LEVEL comment node: the API refuses a
# reply anchored to a comment that is itself inside a thread
# ("Parent comment is already in a thread"). Top-level
# triggers anchor to themselves (anchor == node).
trig_anchor=$(printf '%s' "$trig" | jq -r '.anchor // .node')
trig_is_comment=1
else
# discussion-new (home: the body carried the mention) OR a
# guest comment-mention relay (worker cannot know the comment
# id — notifications for discussions carry none). For guests,
# reconstruct authority: NEWEST mentioning content wins.
trig_body=$(printf '%s' "$disc_json" | jq -r '.body // ""')
trig_author=$(printf '%s' "$disc_json" | jq -r '.author.login // "?"')
trig_assoc=$(printf '%s' "$disc_json" | jq -r '.authorAssociation // "NONE"')
trig_node=$(printf '%s' "$disc_json" | jq -r '.id')
if [ "$THREAD_TYPE" = "discussion" ]; then
# Guest relay cannot know the comment id (discussion
# notifications carry none): reconstruct authority from the
# NEWEST comment that carries a genuine identity-mention
# token (fixed-string, case-insensitive — same token set as
# the issue/PR guest path, incl. the bare app form). Bodies
# are newline-flattened for matching only (a mention token
# never spans lines); full text is re-extracted by node id.
tok_file=$(mktemp)
printf '%s' "$BOT_NAMES_JSON" | jq -r '.[] | "@" + ltrimstr("@") | (., sub("\\[bot\\]$"; ""))' | awk 'NF' | tr 'A-Z' 'a-z' > "$tok_file"
winner_line=$(printf '%s' "$disc_json" | jq -r '
[((.comments.nodes // [])[] | . as $c | ((.replies.nodes // [])[]) | {node: .id, anchor: $c.id, flat: ((.body // "") | gsub("[\\r\\n]+"; " ")), at: .createdAt}),
(((.comments.nodes // [])[]) | {node: .id, anchor: .id, flat: ((.body // "") | gsub("[\\r\\n]+"; " ")), at: .createdAt})]
| sort_by(.at) | reverse | .[] | [.node, .anchor, .flat] | @tsv' \
| while IFS=$'\t' read -r n a f; do
if [ -n "$n" ] && printf '%s' "$f" | grep -qiFf "$tok_file"; then
printf '%s\n' "$n"$'\t'"$a"; break
fi
done | head -1)
rm -f "$tok_file"
winner_node=""; winner_anchor=""
if [ -n "$winner_line" ]; then
winner_node=${winner_line%%$'\t'*}
winner_anchor=${winner_line#*$'\t'}
[ -z "$winner_anchor" ] && winner_anchor="$winner_node"
trig_body=$(printf '%s' "$disc_json" | jq -r --arg n "$winner_node" '
[((.comments.nodes // [])[]) | ((.replies.nodes // [])[]) | {author: (.author.login // "?"), assoc: (.authorAssociation // "NONE"), node: .id, body: (.body // "")}), (((.comments.nodes // [])[]) | {author: (.author.login // "?"), assoc: (.authorAssociation // "NONE"), node: .id, body: (.body // "")})]
| .[] | select(.node == $n) | .body')
trig_author=$(printf '%s' "$disc_json" | jq -r --arg n "$winner_node" '
[((.comments.nodes // [])[]) | ((.replies.nodes // [])[]) | {author: (.author.login // "?"), assoc: (.authorAssociation // "NONE"), node: .id}), (((.comments.nodes // [])[]) | {author: (.author.login // "?"), assoc: (.authorAssociation // "NONE"), node: .id})]
| .[] | select(.node == $n) | .author')
trig_assoc=$(printf '%s' "$disc_json" | jq -r --arg n "$winner_node" '
[((.comments.nodes // [])[]) | ((.replies.nodes // [])[]) | {assoc: (.authorAssociation // "NONE"), node: .id}), (((.comments.nodes // [])[]) | {assoc: (.authorAssociation // "NONE"), node: .id})]
| .[] | select(.node == $n) | .assoc')
trig_node="$winner_node"
trig_anchor="$winner_anchor"
trig_is_comment=1
fi
fi
fi
# --- bot-loop guard (identity family, case-insensitive) --------
author_lc=$(printf '%s' "$trig_author" | tr 'A-Z' 'a-z')
if printf '%s' "$author_lc" | grep -q '\[bot\]$' \
|| printf '%s' "$BOT_NAMES_JSON" | jq -e --arg a "$author_lc" 'map(ascii_downcase) | index($a) != null' >/dev/null 2>&1; then
echo "::notice::Discussion trigger by our own identity; bot-loop guard."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"; exit 0
fi
# --- mention re-validation (shared script; discussions are
# reply-only threads: is_pr=false strips review/compliance).
if ! printf '%s' "$trig_body" | bash .github/scripts/route-comment.sh false | grep -q 'reply'; then
echo "::notice::No mention found in the fetched discussion trigger; skipping."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"; exit 0
fi
# --- budgeted, filtered, hidden-aware context -------------------
# Thread model (mirrors PR review): keep X newest top-level
# comment-threads, each with its N newest replies rendered
# directly beneath it (ownership is explicit). Filtering (hidden
# /noise/ignored) happens BEFORE the window caps. The noise
# pattern MUST bind the pattern first: ". as $p | $lb | test(
# \"(?i)\" + $p)" — an unbound "." in the argument resolves to
# the BODY, turning the filter into a self-match test that
# silently dropped any comment whose body happens to be a valid
# regex (live-caught: a plain user follow-up question vanished).
comments_rendered=$(printf '%s' "$disc_json" | jq -r \
--arg ig "${CONTEXT_IGNORE_AUTHORS:-}" \
--argjson pats "${CONTEXT_FILTER_PATTERNS_JSON:-[]}" \
--argjson bc "$LIM_BC" \
--argjson dt "$LIM_DT" \
--argjson dr "$LIM_DR" '
def noise($b): ($b | ascii_downcase) as $lb | [($pats[] | ascii_downcase) as $p | select($lb | test("(?i)" + $p))] | length > 0;
def clip($s): if ($s | length) > $bc then ($s[0:$bc] + "\n[truncated]") else $s end;
def kept($u): ($u | ascii_downcase) as $l | ($ig | ascii_downcase | split(",") | map(select(length > 0))) | index($l) | not;
[ (.comments.nodes // [])[]
| select(.isMinimized != true)
| select(kept(.author.login // ""))
| select((.body // "") | noise(.) | not)
| . as $c
| ([(($c.replies.nodes // [])[])
| select(.isMinimized != true)
| select(kept(.author.login // ""))
| select((.body // "") | noise(.) | not)]
| sort_by(.createdAt) | reverse | .[0:$dr] | sort_by(.createdAt)) as $show
| (($c.replies.totalCount // (($show | length) + 0)) - ($show | length)) as $rdrop
| { at: ($c.createdAt // "0"),
txt: ("- [\($c.id)] \($c.author.login // "?") (\($c.authorAssociation // "NONE")) at \($c.createdAt):\n\(clip($c.body // ""))"
+ (if ($show | length) > 0 then "\n" + ([$show[] | " ↳ [\(.id)] \(.author.login // "?"): \(clip(.body // ""))"] | join("\n")) else "" end)
+ (if $rdrop > 0 then "\n [\($rdrop) replies not shown here (older than the window or filtered) - retrieve via GraphQL replies() if they matter]" else "" end)) } ]
# SELECTION keeps the newest dt threads (sort descending,
# slice) - the raw page arrives ascending, so slicing it
# directly took the OLDEST dt of the window. Render then
# the OLDEST dt of the window (live-caught class). Render then
# re-sorts chronologically: conversations read top-to-bottom
# the way a human would (fetch newest-first, present
# oldest-first).
| sort_by(.at) | reverse | .[0:$dt] | sort_by(.at)
| map(.txt) | join("\n\n")')
total_threads=$(printf '%s' "$disc_json" | jq -r '.comments.totalCount // (.comments.nodes | length)')
# Count rendered THREAD HEADS only (shape-pinned: "- Login (ASSOC)
# at <ts>:") - comment bodies contain their own "- " bullets that
# must not inflate the count and mask a real drop.
shown_threads=$(printf '%s\n' "$comments_rendered" | grep -cE '^- .+ \((OWNER|MEMBER|COLLABORATOR|CONTRIBUTOR|FIRST_TIME_CONTRIBUTOR|NONE)\) at [0-9]{4}-' || true)
threads_note=""
if [ "$total_threads" -gt "$shown_threads" ]; then
threads_note="$(printf '\n[%s threads not shown here (older than the window or filtered) - retrieve via GraphQL if they matter]' "$((total_threads - shown_threads))")"
fi
cat_hdr=$(printf '%s\n' "$disc_json" | jq -r '"Discussion #\(.number): \(.title)\nCategory: \(.category.name)\(if .category.isAnswerable then " (answerable)" else "" end)\(if .isAnswered == true then " | ANSWERED" elif .isAnswered == false then "" else "" end)\nOpened by \(.author.login // "?") at \(.createdAt)"')
# --- exports ---------------------------------------------------
echo "RESOLVED_IS_PR=false" >> "$GITHUB_ENV"
echo "THREAD_AUTHOR=${trig_author}" >> "$GITHUB_ENV"
echo "RESOLVED_COMMENT_AUTHOR=${trig_author}" >> "$GITHUB_ENV"
echo "RESOLVED_ASSOCIATION=${trig_assoc}" >> "$GITHUB_ENV"
# Step-output mirror: GITHUB_ENV vars are invisible to later
# steps' with: expressions - the requester chain reads outputs.
{ echo "resolved_author=${trig_author}"; echo "resolved_assoc=${trig_assoc}"; } >> "$GITHUB_OUTPUT"
echo "REACTION_SUBJECT_NODE=${trig_node}" >> "$GITHUB_ENV"
echo "DISCUSSION_NODE_ID=$(printf '%s' "$disc_json" | jq -r '.id')" >> "$GITHUB_ENV"
echo "DISCUSSION_TITLE=$(printf '%s' "$disc_json" | jq -r '.title')" >> "$GITHUB_ENV"
if [ "$THREAD_TYPE" = "discussion" ] && [ -n "${COMMENT_ID_INPUT:-}" ]; then
echo "RESOLVED_COMMENT_ID=${COMMENT_ID_INPUT}" >> "$GITHUB_ENV"
else
echo "RESOLVED_COMMENT_ID=" >> "$GITHUB_ENV"
fi
RCB_DELIM="RESOLVED_BODY_EOF_$(openssl rand -hex 8)"
{ printf 'RESOLVED_COMMENT_BODY<<%s\n' "$RCB_DELIM"; printf '%s\n' "$trig_body"; printf '%s\n' "$RCB_DELIM"; } >> "$GITHUB_ENV"
TM_DELIM="GH_TRIGGER_MSG_$(openssl rand -hex 8)"
{ printf 'TRIGGER_MESSAGE<<%s\n' "$TM_DELIM"; printf '%s\n' "$trig_body"; printf '%s\n' "$TM_DELIM"; } >> "$GITHUB_ENV"
TC_DELIM="DISC_CTX_EOF_$(openssl rand -hex 8)"
DISC_BODY=$(printf '%s' "$disc_json" | jq -r '(.body // "") | if length > 8000 then .[0:8000] + "\n[discussion body truncated]" else . end')
{ printf 'THREAD_CONTEXT<<%s\n' "$TC_DELIM"
printf '%s\n\nDiscussion body:\n%s\n\nComment threads (the %s most recent top-level, up to %s replies each, oldest-first below, filtered):\n%s%s\n' "$cat_hdr" "$DISC_BODY" "$LIM_DT" "$LIM_DR" "$comments_rendered" "$threads_note"
printf '%s\n' "$TC_DELIM"; } >> "$GITHUB_ENV"
# DISCUSSION_REPLY_TO_NODE is the OWNING TOP-LEVEL comment node
# (trig_anchor): the API refuses a reply anchored to a comment
# already inside a thread, and REACTION_SUBJECT_NODE stays the
# trigger itself. Exported ONLY when the trigger is a genuine
# comment node - replyToId with a discussion node id is an
# invalid mutation input.
if [ "$trig_is_comment" = "1" ]; then
echo "DISCUSSION_REPLY_TO_NODE=${trig_anchor}" >> "$GITHUB_ENV"
else
echo "DISCUSSION_REPLY_TO_NODE=" >> "$GITHUB_ENV"
fi
echo "::notice::Discussion session validated: @${trig_author} in ${REPO_SLUG}#${THREAD_NUM_INPUT} (${THREAD_TYPE})."
echo "should_proceed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ================= HOME MODE (original path) ====================
if ! comment_json=$(gh api "/repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID_INPUT}" 2>/dev/null); then
echo "::notice::Comment ${COMMENT_ID_INPUT} not readable; nothing to do."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
author=$(printf '%s' "$comment_json" | jq -r '.user.login')
association=$(printf '%s' "$comment_json" | jq -r '.author_association')
# Case-insensitive bot-loop guard (logins are case-insensitive;
# canonical casing follows renames)
author_lc=$(printf '%s' "$author" | tr 'A-Z' 'a-z')
# Bot/agent loop guard: generic [bot] suffix OR resolved identity
# membership (BOT_NAMES_JSON, case-insensitive - set by bot-config.sh).
if printf '%s' "$author_lc" | grep -q '\[bot\]$' \
|| printf '%s' "$BOT_NAMES_JSON" | jq -e --arg a "$author_lc" 'map(ascii_downcase) | index($a) != null' >/dev/null 2>&1; then
echo "::notice::Comment authored by ${author} (bot/agent); bot-loop guard."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Note: the fetched body is the comment's text AT RUN TIME - if the
# author edited it after the router dispatched, this re-validation
# judges the CURRENT text (edited-in trigger words count; edited-out
# mentions cause a skip). That is the safe direction.
body=$(printf '%s' "$comment_json" | jq -r '.body')
# PR or plain issue (the comment endpoint does not distinguish).
is_pr=$(gh api "/repos/${GITHUB_REPOSITORY}/issues/${THREAD_NUM_INPUT}" \
--jq 'if .pull_request then "true" else "false" end' 2>/dev/null || echo unknown)
# Comment-thread consistency: the fetched comment must belong to the
# dispatched thread (guards manual dispatch with mismatched inputs).
comment_thread=$(printf '%s' "$comment_json" | jq -r '.issue_url' | sed -n 's:.*/issues/\([0-9][0-9]*\)$:\1:p')
if [ "$comment_thread" != "$THREAD_NUM_INPUT" ]; then
echo "::notice::Comment ${COMMENT_ID_INPUT} belongs to thread #${comment_thread}, not #${THREAD_NUM_INPUT}; refusing mismatched dispatch."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Mention re-validation via the SHARED script (same cleaning and
# matching as the router). Bot Reply triggers on the MENTION route
# only - original guard semantics: a bare /mirrobot-review comment
# routes to PR Review, not to a conversational reply.
if ! printf '%s' "$body" | bash .github/scripts/route-comment.sh "$is_pr" | grep -q 'reply'; then
echo "::notice::No mention found in non-quoted, non-code text. Skipping."
echo "should_proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::notice::Valid mention from @${author}; proceeding."
# SECURITY: unguessable delimiter - a static/predictable one could be
# forged by a body line, letting comment content inject additional
# GITHUB_ENV directives (e.g. faking RESOLVED_ASSOCIATION).
COMMENT_DELIMITER="RESOLVED_BODY_EOF_$(openssl rand -hex 8)"
{
echo "RESOLVED_COMMENT_ID=${COMMENT_ID_INPUT}"
echo "RESOLVED_COMMENT_AUTHOR=${author}"
echo "RESOLVED_ASSOCIATION=${association}"
# Step-output mirror (GITHUB_ENV is invisible in with:).
{ echo "resolved_author=${author}"; echo "resolved_assoc=${association}"; echo "resolved_is_pr=${is_pr}"; } >> "$GITHUB_OUTPUT"
echo "RESOLVED_IS_PR=${is_pr}"
printf 'RESOLVED_COMMENT_BODY<<%s\n' "$COMMENT_DELIMITER"
printf '%s\n' "$body"
printf '%s\n' "$COMMENT_DELIMITER"
} >> "$GITHUB_ENV"
# Trigger-message block: the mention comment IS the request.
TM_DELIMITER="GH_TRIGGER_MSG_$(openssl rand -hex 8)"
{
printf 'TRIGGER_MESSAGE<<%s\n' "$TM_DELIMITER"
printf '%s\n' "$body"
printf '%s\n' "$TM_DELIMITER"
} >> "$GITHUB_ENV"
echo "should_proceed=true" >> "$GITHUB_OUTPUT"
- name: Fast eyes on trigger (account mode)
# Same rationale as pr-review's fast-eyes: account-mode eyes within
# seconds of the mention, before checkout/setup. Only fires once the
# validate gate passed (mention confirmed in actual content). The
# regular react step below is the app-mode fallback. Discussions
# have no REST reactions endpoint — GraphQL addReaction on the
# subject node (probe-verified live 2026-09-09).
id: fast_eyes
if: steps.validate.outputs.should_proceed == 'true' && (inputs.commentId != '' || env.THREAD_TYPE != '') && env.ACCOUNT_TOKEN_SET == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.ACCOUNT_GH_TOKEN }}
COMMENT_ID: ${{ inputs.commentId }}
run: |
posted=false
if [ -n "${THREAD_TYPE:-}" ]; then
if gh api graphql -f query="mutation { addReaction(input: {subjectId: \"${REACTION_SUBJECT_NODE}\", content: EYES}) { reaction { content } } }" >/dev/null 2>&1; then
posted=true
fi
elif gh api --method POST -H "Accept: application/vnd.github+json" \
"/repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes; then
posted=true
fi
echo "posted=$posted" >> "$GITHUB_OUTPUT"
- name: Checkout repository
if: steps.validate.outputs.should_proceed == 'true'
uses: actions/checkout@v4
with:
persist-credentials: false # no credentials in .git/config (public repo: anonymous fetches still work)
# Builds the factual requester context line (GitHub author association +
# optional vars.TRUSTED_AGENT_USERS list) consumed by the security brief.
# Any user may trigger the bot; this only informs the agent's judgment.
# Manual-dispatch requester identity (workflow_dispatch carries no
# author_association): resolve the dispatch actor's real standing via
# the API so the security brief is factual - the same fix pr-review
# got (live-caught there: the owner rendered as NONE/unverified).
- name: Resolve dispatch actor association
id: dispatch_assoc
if: github.event_name == 'workflow_dispatch' && steps.validate.outputs.should_proceed == 'true' && steps.validate.outputs.resolved_assoc == ''
env:
GH_TOKEN: ${{ github.token }}
ACTOR_LOGIN: ${{ github.actor }}
run: |
perm=$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR_LOGIN}/permission" --jq '.permission' 2>/dev/null || echo "")
case "$perm" in
admin) echo "association=OWNER" >> "$GITHUB_OUTPUT" ;;
maintain|write|triage|read) echo "association=COLLABORATOR" >> "$GITHUB_OUTPUT" ;;
*) echo "association=" >> "$GITHUB_OUTPUT" ;;
esac
- name: Build requester context
if: github.event_name == 'workflow_dispatch'
id: requester
uses: ./.github/actions/requester-context
with:
login: ${{ steps.validate.outputs.resolved_author || github.actor }}
association: ${{ steps.validate.outputs.resolved_assoc || steps.dispatch_assoc.outputs.association }}
trusted-users: ${{ vars.TRUSTED_AGENT_USERS }}
trigger-note: "${{ format('routed comment on {0}', inputs.threadNumber) }}"
- name: Bot Setup
if: steps.validate.outputs.should_proceed == 'true'
id: setup
uses: ./.github/actions/bot-setup
with:
account-token: ${{ secrets.ACCOUNT_GH_TOKEN }}
bot-app-id: ${{ secrets.BOT_APP_ID }}
bot-private-key: ${{ secrets.BOT_PRIVATE_KEY }}
opencode-api-key: ${{ secrets.OPENCODE_API_KEY }}
opencode-model: ${{ secrets.OPENCODE_MODEL }}
opencode-fast-model: ${{ secrets.OPENCODE_FAST_MODEL }}
opencode-config-json: ${{ secrets.OPENCODE_CONFIG_JSON }}
# Per-agent model override (AGENT_MODELS_JSON variable) and
# plugin materialization (OPENCODE_PLUGINS_JSON / _1.._5).
agent-key: bot-reply
agent-models-json: ${{ vars.AGENT_MODELS_JSON }}
plugins-json: ${{ vars.OPENCODE_PLUGINS_JSON }}
plugins-json-1: ${{ vars.OPENCODE_PLUGINS_JSON_1 }}
plugins-json-2: ${{ vars.OPENCODE_PLUGINS_JSON_2 }}
plugins-json-3: ${{ vars.OPENCODE_PLUGINS_JSON_3 }}
plugins-json-4: ${{ vars.OPENCODE_PLUGINS_JSON_4 }}
plugins-json-5: ${{ vars.OPENCODE_PLUGINS_JSON_5 }}
- name: Add reaction to comment (lifecycle start)
# Eyes on the mention comment via the shared lifecycle script; the
# session-success/failure steps below transition it to rocket/confused.
# Skipped when fast-eyes (account mode) already posted. Discussions:
# GraphQL regime on the subject node (comment for comment-triggers,
# the discussion itself for body-triggers).
if: steps.validate.outputs.should_proceed == 'true' && steps.fast_eyes.outputs.posted != 'true' && (inputs.commentId != '' || env.THREAD_TYPE != '')
continue-on-error: true
env:
GH_TOKEN: ${{ steps.setup.outputs.token }}
COMMENT_ID: ${{ env.RESOLVED_COMMENT_ID }}
# Workspace copy is SAFE here: this step runs on the default-branch
# checkout BEFORE the PR-head checkout (the /tmp copy is not saved
# yet). Never move this step below the PR checkout — past it, the
# workspace script is PR-controlled content.
run: |
if [ -n "${THREAD_TYPE:-}" ]; then
bash .github/scripts/react.sh start discussion "${REACTION_SUBJECT_NODE}"
else
bash .github/scripts/react.sh start comment "$COMMENT_ID"
fi
- name: Gather Full Thread Context
if: steps.validate.outputs.should_proceed == 'true'
id: context
env:
GH_TOKEN: ${{ steps.setup.outputs.token }}
CONTEXT_FILTER_PATTERNS_JSON: ${{ env.CONTEXT_FILTER_PATTERNS_JSON }}
# SECURITY: untrusted comment content reaches the script only through
# environment variables (resolved from the API by id in the first
# step) - never via ${{ }} interpolation inside run:, which is
# evaluated before the shell starts (command injection).
COMMENT_BODY: ${{ env.RESOLVED_COMMENT_BODY }}
COMMENT_AUTHOR: ${{ env.RESOLVED_COMMENT_AUTHOR }}
ISSUE_IS_PR: ${{ env.RESOLVED_IS_PR }}
run: |
# (No set -e/-u here by design - HEAD behavior: fetch/jq failures
# degrade to empty context instead of aborting the thread reply.)
# DISCUSSION MODE short-circuit: same shape — the resolve step
# built the budgeted discussion context (GraphQL; there is no
# fetch-pr-discussion equivalent for discussions) and already
# set every NEW_COMMENT_*/trigger env.
if [ -n "${THREAD_TYPE:-}" ]; then
echo "NEW_COMMENT_AUTHOR=$COMMENT_AUTHOR" >> $GITHUB_ENV
D_DELIM="GH_BODY_DELIMITER_$(openssl rand -hex 8)"
{ echo "NEW_COMMENT_BODY<<$D_DELIM"; echo "$COMMENT_BODY"; echo "$D_DELIM"; } >> "$GITHUB_ENV"
echo "IS_PR=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Common Info
echo "NEW_COMMENT_AUTHOR=$COMMENT_AUTHOR" >> $GITHUB_ENV
# Use a unique delimiter for safety
COMMENT_DELIMITER="GH_BODY_DELIMITER_$(openssl rand -hex 8)"
{ echo "NEW_COMMENT_BODY<<$COMMENT_DELIMITER"; echo "$COMMENT_BODY"; echo "$COMMENT_DELIMITER"; } >> "$GITHUB_ENV"
# Determine if PR or Issue
if [ "$ISSUE_IS_PR" = "true" ]; then
IS_PR="true"
else
IS_PR="false"
fi
echo "IS_PR=$IS_PR" >> $GITHUB_OUTPUT
# Define a unique, random delimiter for the main context block
CONTEXT_DELIMITER="GH_CONTEXT_DELIMITER_$(openssl rand -hex 8)"
# Fetch and Format Context based on type
if [[ "$IS_PR" == "true" ]]; then
# PR metadata + body framing; the DISCUSSION (comments, reviews,
# inline comments - the three-block separation identical to PR
# Review / Compliance Check) is built by the shared
# fetch-pr-discussion.sh, which also exports PREVIOUS_BOT_REVIEWS
# and AGENT_REVIEW_HISTORY (your own earlier reviews on this PR).
# GraphQL note (live-verified 2026-08-17, account mode, PR #33 lab
# battery): these gh pr view fields run green under a classic
# public_repo PAT. The known-bad field is reviewRequests/…
# requester.login — it demands user-profile scopes the PAT lacks
# (see compliance-check.yml pr_info for the REST pattern if a
# field ever needs migrating). Do NOT add reviewRequests here.
pr_json=$(gh pr view $THREAD_NUMBER --repo ${{ github.repository }} --json author,title,body,createdAt,state,headRefName,baseRefName,headRefOid,additions,deletions,closingIssuesReferences,headRepository)
timeline_data=$(gh api "/repos/${{ github.repository }}/issues/$THREAD_NUMBER/timeline")
echo "PR_HEAD_SHA=$(echo "$pr_json" | jq -r .headRefOid)" >> $GITHUB_ENV
# Trusted SHA file (see pr-review.yml note): agents reference the
# file instead of hand-typing hex SHAs.
printf '%s\n' "$(echo "$pr_json" | jq -r .headRefOid)" > /tmp/head_sha.txt
echo "THREAD_AUTHOR=$(echo "$pr_json" | jq -r .author.login)" >> $GITHUB_ENV
echo "BASE_BRANCH=$(echo "$pr_json" | jq -r .baseRefName)" >> $GITHUB_ENV
author=$(echo "$pr_json" | jq -r .author.login)
created_at=$(echo "$pr_json" | jq -r .createdAt)
base_branch=$(echo "$pr_json" | jq -r .baseRefName)
head_branch=$(echo "$pr_json" | jq -r .headRefName)
state=$(echo "$pr_json" | jq -r .state)
additions=$(echo "$pr_json" | jq -r .additions)
deletions=$(echo "$pr_json" | jq -r .deletions)
# REST scalar, not gh's capped-at-100 commits list (see
# pr-review.yml for the live catch).
total_commits=$(gh api "repos/${{ github.repository }}/pulls/$THREAD_NUMBER" --jq '.commits // 0' 2>/dev/null || echo 0)
title=$(echo "$pr_json" | jq -r .title)
body=$(echo "$pr_json" | jq -r '.body // "(No description provided)"')
# One-line summary only; the per-file list lives in the kit
# (git-native numstat - the files API's per-file counts go
# null on huge PRs; see pr-review.yml for the live catch).
files_tsv=$(gh api "repos/${{ github.repository }}/pulls/$THREAD_NUMBER/files?per_page=100" --paginate --jq '.[] | [.status, .filename] | @tsv' 2>/dev/null || true)
changed_files_count=$(gh api "repos/${{ github.repository }}/pulls/$THREAD_NUMBER" --jq '.changed_files // 0' 2>/dev/null || printf '%s\n' "$files_tsv" | awk 'NF' | wc -l | tr -d ' ')
changed_files_summary=$(printf '%s\n' "$files_tsv" | awk -F'\t' 'NF==2 {
st = toupper(substr($1,1,1))
if (st == "C") st = "M"
if (st == "R") st = "M"
cnt[st]++
} END {
printf "(A %d / M %d / D %d)", cnt["A"]+0, cnt["M"]+0, cnt["D"]+0
}')
linked_issues_content=""
issue_numbers=$(echo "$pr_json" | jq -r '.closingIssuesReferences[].number')
if [ -z "$issue_numbers" ]; then
linked_issues="No issues are formally linked for closure by this PR."
else
for number in $issue_numbers; do
issue_details_json=$(gh issue view "$number" --repo "${{ github.repository }}" --json title,body 2>/dev/null || echo "{}")
issue_title=$(echo "$issue_details_json" | jq -r '.title // "Title not available"')
# Body capped: linked issues are CONTEXT (the agent can read
# the full issue on demand) - a 64KB body x N linked issues
# must not silently eat the prompt.
issue_body=$(echo "$issue_details_json" | jq -r --argjson bc 4000 '(.body // "Body not available") | if length > $bc then .[0:$bc] + "\n[linked-issue body truncated - read the full issue if it matters]" else . end')
linked_issues_content+=$(printf "<issue>\n <number>#%s</number>\n <title>%s</title>\n <body>\n%s\n</body>\n</issue>\n" "$number" "$issue_title" "$issue_body")
done
linked_issues=$linked_issues_content
fi
references=$(echo "$timeline_data" | jq -r '.[] | select(.event == "cross-referenced") | .source.issue | "- Mentioned in \(.html_url | if contains("/pull/") then "PR" else "Issue" end): #\(.number) - \(.title)"')
if [ -z "$references" ]; then references="This PR has not been mentioned in other issues or PRs."; fi
# Metadata/body prefix handed to the shared script; it prepends
# this verbatim to THREAD_CONTEXT.
PREFIX_TEXT=$(printf 'Type: Pull Request\nPR Number: #%s\nTitle: %s\nAuthor: %s\nCreated At: %s\nBase Branch (target): %s\nHead Branch (source): %s\nState: %s\nAdditions: %s\nDeletions: %s\nTotal Commits: %s\nChanged Files: %s %s - per-file compact list: /tmp/kit/%s/changed-files.txt\n<pull_request_body>\n%s\n---\n%s\n</pull_request_body>\n<linked_issues>\n%s\n</linked_issues>\n<cross_references>\n%s\n</cross_references>' \
"$THREAD_NUMBER" "$title" "$author" "$created_at" "$base_branch" "$head_branch" "$state" "$additions" "$deletions" "$total_commits" "$changed_files_count" "$changed_files_summary" "$THREAD_NUMBER" "$title" "$body" "$linked_issues" "$references")
if ! PREFIX_TEXT="$PREFIX_TEXT" bash .github/scripts/fetch-pr-discussion.sh "$THREAD_NUMBER"; then
echo "::warning::Discussion context unavailable - proceeding with metadata-only context."
CTX_DELIMITER="GH_THREAD_CONTEXT_$(openssl rand -hex 8)"
{
printf 'THREAD_CONTEXT<<%s\n' "$CTX_DELIMITER"
printf '%s\n' "$PREFIX_TEXT"
printf '(Discussion context unavailable - comments and reviews could not be fetched.)\n'
printf '%s\n' "$CTX_DELIMITER"
} >> "$GITHUB_ENV"
echo "PREVIOUS_BOT_REVIEWS=(No previous reviews by this agent yet.)" >> "$GITHUB_ENV"
echo "AGENT_REVIEW_HISTORY=(No older reviews by this agent.)" >> "$GITHUB_ENV"
fi
else # It's an Issue
issue_data=$(gh issue view $THREAD_NUMBER --repo ${{ github.repository }} --json author,title,body,createdAt,state,comments)
timeline_data=$(gh api "/repos/${{ github.repository }}/issues/$THREAD_NUMBER/timeline")
echo "THREAD_AUTHOR=$(echo "$issue_data" | jq -r .author.login)" >> $GITHUB_ENV
# Normalize FIRST: empty CONTEXT_LIMITS_JSON behaves as {} (empty
# jq input emits nothing; downstream head -c ""/argjson "" would
# abort - use-before-definition live-caught by the diff audit).
CLJ="${CONTEXT_LIMITS_JSON:-}"
[ -z "$CLJ" ] && CLJ='{}'
# Prepare metadata
author=$(echo "$issue_data" | jq -r .author.login)
created_at=$(echo "$issue_data" | jq -r .createdAt)
state=$(echo "$issue_data" | jq -r .state)
title=$(echo "$issue_data" | jq -r .title)
body=$(echo "$issue_data" | jq -r --argjson bc "$(printf '%s' "$CLJ" | jq -r '."body-chars" // 4000')" '(.body // "(No description provided)") | if length > $bc then .[0:$bc] + "\n[body truncated]" else . end')
# Prepare comments - SAME discipline as fetch-pr-discussion.sh:
# filter-before-cap (ignored authors + AI-reviewer noise patterns
# never consume a slot), then newest-N survivors, then per-body
# clip. gh's --json comments window is ~100; N comes from
# CONTEXT_LIMITS_JSON .comments.
if ! printf '%s' "${CONTEXT_FILTER_PATTERNS_JSON:-}" | jq -e 'type == "array"' >/dev/null 2>&1; then
FILTER_PATTERNS_JSON='["rate limited by coderabbit\\.ai","No actionable comments were generated","Review skipped","Too many files","<!-- greptile-status -->","Too many files changed for review"]'
else
FILTER_PATTERNS_JSON="$CONTEXT_FILTER_PATTERNS_JSON"
fi
comments=$(echo "$issue_data" | jq -r \
--arg ignore_authors "$(printf '%s' "$CONTEXT_IGNORE_AUTHORS" | tr '[:upper:]' '[:lower:]')" \
--argjson patterns "$FILTER_PATTERNS_JSON" \
--argjson limComments "$(printf '%s' "$CLJ" | jq -r '.comments // 30')" \
--argjson bodyChars "$(printf '%s' "$CLJ" | jq -r '."body-chars" // 4000')" '
($ignore_authors | split(",") | map(select(length > 0))) as $ignored |
def noisy: ((.body // "") as $b | [ $patterns[] | . as $p | select($b | test("(?i)" + $p)) ] | length > 0);
def clip($s): if ($s | length) > $bodyChars then ($s[0:$bodyChars] + "\n[body truncated]") else $s end;
(.comments // [])
| map(select(((.author.login // "" | ascii_downcase) as $login | ($ignored | index($login)) | not) and (noisy | not)))
| map(select(.isMinimized != true))
| if length > $limComments then .[($limComments * -1):] else . end
| if length > 0 then
map("- [id " + ((.id // "?") | tostring) + "] " + (.author.login // "unknown") + " at " + (.createdAt // "N/A") + ":\n" + clip((.body // "") | tostring) + "\n") | join("")
else "No comments have been posted yet."
end')
# Prepare cross-references
references=$(echo "$timeline_data" | jq -r '.[] | select(.event == "cross-referenced") | .source.issue | "- Mentioned in \(.html_url | if contains("/pull/") then "PR" else "Issue" end): #\(.number) - \(.title)"')
if [ -z "$references" ]; then references="No other issues or PRs have mentioned this thread."; fi
# Step 1: Write the header
echo "THREAD_CONTEXT<<$CONTEXT_DELIMITER" >> "$GITHUB_ENV"
# Step 2: Append the content line by line
echo "Type: Issue" >> "$GITHUB_ENV"
echo "Issue Number: #$THREAD_NUMBER" >> "$GITHUB_ENV"
echo "Title: $title" >> "$GITHUB_ENV"
echo "Author: $author" >> "$GITHUB_ENV"
echo "Created At: $created_at" >> "$GITHUB_ENV"
echo "State: $state" >> "$GITHUB_ENV"
echo "<issue_body>" >> "$GITHUB_ENV"
echo "$body" >> "$GITHUB_ENV"
echo "</issue_body>" >> "$GITHUB_ENV"
echo "<issue_comments>" >> "$GITHUB_ENV"
echo "$comments" >> "$GITHUB_ENV"
echo "</issue_comments>" >> "$GITHUB_ENV"
echo "<cross_references>" >> "$GITHUB_ENV"
echo "$references" >> "$GITHUB_ENV"
echo "</cross_references>" >> "$GITHUB_ENV"
# Step 3: Write the footer
echo "$CONTEXT_DELIMITER" >> "$GITHUB_ENV"
# Plain issue thread: no formal reviews exist - keep the
# three-block vars defined so prompt substitution stays clean.
echo "PREVIOUS_BOT_REVIEWS=(Not a PR thread - no formal reviews.)" >> "$GITHUB_ENV"
echo "AGENT_REVIEW_HISTORY=(none)" >> "$GITHUB_ENV"
fi
- name: Clear pending bot review
if: steps.validate.outputs.should_proceed == 'true' && steps.context.outputs.IS_PR == 'true'
env:
GH_TOKEN: ${{ steps.setup.outputs.token }}
run: |
pending_review_ids=$(gh api --paginate \
"/repos/${GITHUB_REPOSITORY}/pulls/$THREAD_NUMBER/reviews" \
| jq -r --argjson bots "$BOT_NAMES_JSON" '.[]? | select((.state // "") == "PENDING" and (((.user.login // "" | ascii_downcase) as $login | $bots | index($login)))) | .id' \
| sort -u)
if [ -z "$pending_review_ids" ]; then
echo "No pending bot reviews to clear."
exit 0
fi
while IFS= read -r review_id; do
[ -z "$review_id" ] && continue
if gh api \
--method DELETE \
-H "Accept: application/vnd.github+json" \
"/repos/${GITHUB_REPOSITORY}/pulls/$THREAD_NUMBER/reviews/$review_id"; then
echo "Cleared pending review $review_id"
else
echo "::warning::Failed to clear pending review $review_id"
fi
done <<< "$pending_review_ids"
- name: Determine Review Type and Last Reviewed SHA
if: steps.validate.outputs.should_proceed == 'true' && steps.context.outputs.IS_PR == 'true'
id: review_type
env:
GH_TOKEN: ${{ steps.setup.outputs.token }}
QUERY_REPO: ${{ github.repository }}
run: |
# HIDDEN = GONE (operator-intent doctrine): minimized reviews and
# comments never count as reviewed coverage. Review-level hide does
# not propagate to its comments and is GraphQL-only -
# minimized-nodes.sh is the single source (QUERY_REPO-aware for the
# guest PR path). Fail-closed: unverifiable hidden state skips the
# hidden filtering with a warning, never trusts blindly.
hidden_json='{"reviews":[],"comments":[]}'
hidden_json=$(bash .github/scripts/minimized-nodes.sh "$THREAD_NUMBER") || {
hidden_json='{"reviews":[],"comments":[]}'
echo "::warning::Hidden-state fetch failed; hidden reviews may be counted as coverage this run."
}
pr_summary_payload=$(gh pr view "$THREAD_NUMBER" --repo "$QUERY_REPO" --json comments,reviews)
detect_json=$(echo "$pr_summary_payload" | jq -c --argjson bots "$BOT_NAMES_JSON" --argjson hidden "$hidden_json" '
def ts(x): if (x//""=="") then null else x end;
def items:
[ (.comments[]? | .id as $cid | select((.isMinimized != true) and (($hidden.comments | index($cid)) == null)) | select((.author.login // "" | ascii_downcase) as $a | $bots | index($a)) | {type:"comment", body:(.body//""), ts:(.updatedAt // .createdAt // "")} ),
(.reviews[]? | .id as $rid | select(($hidden.reviews | index($rid)) == null) | select((.author.login // "" | ascii_downcase) as $a | $bots | index($a)) | {type:"review", body:(.body//""), ts:(.submittedAt // .updatedAt // .createdAt // "")} )
] | sort_by(.ts) | .;
def has_phrase: (.body//"") | test("This review was generated by an AI assistant\\.?");
def has_marker: (.body//"") | test("<!--\\s*last_reviewed_sha:[a-f0-9]{7,40}\\s*-->");
{ latest_phrase: (items | map(select(has_phrase)) | last // {}),
latest_marker: (items | map(select(has_marker)) | last // {}),
all_markers: [ items | map(select(has_marker)) | reverse | .[] | {ts: .ts, sha: (.body | capture("(?<s><!--\\s*last_reviewed_sha:(?<h>[a-f0-9]{7,40})\\s*-->").h // "")} ] }
')
latest_phrase_ts=$(echo "$detect_json" | jq -r '.latest_phrase.ts // ""')
latest_marker_ts=$(echo "$detect_json" | jq -r '.latest_marker.ts // ""')
latest_marker_body=$(echo "$detect_json" | jq -r '.latest_marker.body // ""')
echo "is_first_review=false" >> $GITHUB_OUTPUT
resolved_sha=""
if [ -z "$latest_phrase_ts" ] && [ -z "$latest_marker_ts" ]; then
echo "is_first_review=true" >> $GITHUB_OUTPUT
fi
# ---- rebase ladder (same doctrine as pr-review.yml) ----------------
# Walk ALL marker SHAs newest-first; anchor on the newest REACHABLE
# reviewed state (ancestry vs the PR HEAD object, fetched first -
# never HEAD, which is the default-branch checkout). None reachable
# -> FOLLOW-UP awareness + REBASE_CONTEXT instead of an orphaned
# anchor presenting a full diff as incremental.
PR_HEAD_OBJ=""
if [ "$QUERY_REPO" = "$GITHUB_REPOSITORY" ]; then
if git fetch origin "pull/$THREAD_NUMBER/head" >/dev/null 2>&1; then
PR_HEAD_OBJ=$(git rev-parse FETCH_HEAD 2>/dev/null || echo "")
fi
fi
candidates=$(echo "$detect_json" | jq -r '.all_markers[] | select(.sha != "") | [.sha, .ts] | @tsv')
if [ -n "$PR_HEAD_OBJ" ] && [ -n "$candidates" ]; then
while IFS="$(printf '\t')" read -r csha cts; do
[ -n "$csha" ] || continue
if git cat-file -e "$csha" 2>/dev/null && git merge-base --is-ancestor "$csha" "$PR_HEAD_OBJ" 2>/dev/null; then
resolved_sha="$csha"
echo "Using newest REACHABLE reviewed state: $resolved_sha"
break
fi
done <<< "$candidates"
if [ -z "$resolved_sha" ]; then
newest_sha=$(printf '%s\n' "$candidates" | head -1 | cut -f1)
{
echo "REBASE_CONTEXT<<RB_CTX_EOF"
echo "NOTE - branch history was rewritten (force-push/rebase) after the last review (${newest_sha:0:10}); every previously reviewed state is unreachable from the current head, so no incremental diff exists. The FULL diff is provided. Your prior reviews are in the review-memory blocks: reconstruct what still applies yourself."
echo "RB_CTX_EOF"
} >> "$GITHUB_ENV"
echo "Rebase detected: all reviewed states unreachable."
fi
elif [ -n "$latest_marker_ts" ] && { [ -z "$latest_phrase_ts" ] || [ "$latest_marker_ts" \> "$latest_phrase_ts" ] || [ "$latest_marker_ts" = "$latest_phrase_ts" ]; }; then
# No head object to verify reachability: classic newest-marker.
resolved_sha=$(printf "%s" "$latest_marker_body" | sed -nE 's/.*<!--\s*last_reviewed_sha:([a-f0-9]{7,40})\s*-->.*/\1/p' | head -n1)
fi
if [ -z "$resolved_sha" ] && [ -n "$latest_phrase_ts" ] && [ -z "${REBASE_CONTEXT:-}" ]; then
reviews_json=$(gh api "/repos/$QUERY_REPO/pulls/$THREAD_NUMBER/reviews" || echo '[]')
resolved_sha=$(echo "$reviews_json" | jq -r --argjson bots "$BOT_NAMES_JSON" --argjson hidden "$hidden_json" '[.[] | .node_id as $nid | select(($hidden.reviews | index($nid)) == null) | select((.user.login // "" | ascii_downcase) as $u | $bots | index($u)) | .commit_id] | last // ""')
fi
if [ -n "$resolved_sha" ]; then
echo "last_reviewed_sha=$resolved_sha" >> $GITHUB_OUTPUT
else
echo "last_reviewed_sha=" >> $GITHUB_OUTPUT
fi
# SECURITY: capture every file the agent consumes from the DEFAULT BRANCH
# checkout (done above, before any PR-head checkout) into /tmp.
# actions/checkout cannot write outside the workspace, so a PR can never
# overwrite these copies. The scrub script is invoked after every later
# checkout, exactly as in the other agent workflows.
- name: Save trusted artifacts (prompt parts + scrub script)
if: steps.validate.outputs.should_proceed == 'true'
run: |
cp .github/prompts/security-brief.md /tmp/security-brief.md
cp -r .github/prompts/parts /tmp/parts
cp -r .github/prompts/manifests /tmp/manifests
cp .github/scripts/assemble-prompt.sh /tmp/assemble-prompt.sh
cp .github/scripts/scrub-workspace.sh /tmp/scrub-workspace.sh
cp .github/scripts/share-filter.sh /tmp/share-filter.sh
cp .github/scripts/fetch-roster.sh /tmp/fetch-roster.sh
cp .github/scripts/bot-config.sh /tmp/bot-config.sh
cp .github/scripts/react.sh /tmp/react.sh
cp .github/scripts/opencode-cleanup.sh /tmp/opencode-cleanup.sh
cp .github/scripts/fetch-pr-discussion.sh /tmp/fetch-pr-discussion.sh
cp .github/scripts/generate-review-kit.sh /tmp/generate-review-kit.sh
cp .github/scripts/split-diff.sh /tmp/split-diff.sh
cp .github/scripts/minimized-nodes.sh /tmp/minimized-nodes.sh
chmod +x /tmp/scrub-workspace.sh /tmp/assemble-prompt.sh /tmp/fetch-pr-discussion.sh /tmp/generate-review-kit.sh /tmp/react.sh /tmp/opencode-cleanup.sh /tmp/share-filter.sh /tmp/split-diff.sh /tmp/minimized-nodes.sh
bash /tmp/assemble-prompt.sh --verify
# Resolve identity + trigger stems (exports BOT_NAMES_JSON / BOT_TRIGGER_STEMS)
bash /tmp/bot-config.sh
- name: Stash local action for the post phase
# A PR head that deletes/rewrites .github/actions/bot-setup breaks the
# composite action's post phase the same way the guest checkout did.
if: steps.validate.outputs.should_proceed == 'true' && steps.context.outputs.IS_PR == 'true'
run: |
mkdir -p "$RUNNER_TEMP/bot-setup-backup"
cp -r .github/actions/bot-setup/. "$RUNNER_TEMP/bot-setup-backup/"
- name: Checkout PR head
if: steps.validate.outputs.should_proceed == 'true' && steps.context.outputs.IS_PR == 'true'
uses: actions/checkout@v4
with:
ref: ${{ env.PR_HEAD_SHA }}
token: ${{ steps.setup.outputs.token }}
persist-credentials: false # keep the App token out of .git/config (readable via cat .git/config)
fetch-depth: 0 # Full history needed for git operations and code analysis
# Issue sessions: full-history, token-carrying checkout (restored
# 2026-09-15 - same lost-in-surgery class as the PR-diff step; without
# it the base checkout is shallow+anonymous and issue-thread
# contributions cannot branch/commit/push).