Repository navigation
Expand file tree
/
Copy pathaction.yml
More file actions
1842 lines (1773 loc) · 100 KB
/
Copy pathaction.yml
File metadata and controls
1842 lines (1773 loc) · 100 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
# OrcaCode Review — reusable composite action.
#
# Consumers reference this with `uses:` instead of copying the folder; see
# workflows/orca-code-review.yml for the ~15-line example workflow.
#
# One review pass per pull-request push. Findings post as inline PR comments and
# a severity gate can fail the check to block the merge.
#
# Model selection is NOT hardcoded here. The action emits raw facts
# (x-cr-prev-tier / x-cr-prev-p0p1) and never names a model; OCR cannot send
# custom headers, so an in-job loopback proxy (scripts/fact-proxy.mjs) stamps
# those facts onto its requests and the workspace router's DSL recipe
# (recipes/orcacode-review.dsl.yaml) turns them into a concrete model. Changing which
# model reviews your code is therefore a recipe edit in your own workspace, with
# no Action version to bump.
#
# Around the review: dashboard settings (scripts/settings.mjs, fetched
# fail-open from the gateway; `settings: "false"` skips the fetch and makes
# the workflow file authoritative) gate auto reviews (auto_review / trigger),
# retune fix-first/block-on, and switch on quiet mode (P2 muted at posting via
# scripts/quiet-filter.mjs — the gate/report still see true counts), an
# exhaustive mode (extra engine passes, deduped by
# scripts/exhaustive-merge.mjs), and a server-side rubric override; an
# oversized-diff guard (scripts/diff-guard.mjs) decides review/skip BEFORE the
# engine ever runs (a skip posts a notice and, by default, FAILS the check so
# the merge gate can't be padded around — `on-oversized-diff: "pass"` makes it
# advisory); one summary per PR (scripts/summary-comment.mjs) is written into
# a marker region of the PR description (scripts/inject-summary.mjs) so it stays
# pinned at the top, replaced in place on every push; and a best-effort per-tier run report
# (scripts/report.mjs) posts severity counts — never code — to the OrcaRouter
# control plane after the gate.
name: "OrcaCode Review"
description: "AI code review for every pull request, powered by OrcaRouter"
author: "OrcaRouter"
branding:
icon: "eye"
color: "blue"
inputs:
orcarouter-api-key:
description: "OrcaRouter API key."
required: true
orcarouter-url:
description: "OrcaRouter chat-completions endpoint."
required: false
default: "https://api.orcarouter.ai/v1/chat/completions"
github-token:
description: "Token used to fetch the PR head, post review comments, and react to a /orcacode-review command."
required: false
default: ${{ github.token }}
brand:
description: "Name shown on PR comments."
required: false
default: "OrcaCode Review"
router:
description: >-
OrcaRouter router alias (orcarouter/<name>) that owns model selection.
The action names no models: it injects raw facts (x-cr-prev-tier /
x-cr-prev-p0p1) as headers and this router's DSL recipe turns them into a
concrete model. See recipes/orcacode-review.dsl.yaml.
required: false
default: "orcarouter/orcacode-review"
fix-first:
description: >-
Severities that stop an exhaustive review early: once a pass has found
one, no further passes are added, because that finding is what to fix
first. NO EFFECT unless exhaustive is on. It does not change what the
reviewer is asked to look for, and it does not block the merge — that
is block-on.
required: false
default: "P0,P1"
block-on:
description: "Severities that fail the check (block the merge)."
required: false
default: "P0,P1"
auto-review-authors:
description: >-
Comma-separated GitHub author-association allowlist for AUTOMATIC
reviews (pull_request events). Empty = review everyone (default). On a
PUBLIC repo, pull_request_target bypasses GitHub's fork-approval gate
and the review key is wallet-metered, so an external user can trigger
paid reviews by opening PRs — set e.g.
"OWNER,MEMBER,COLLABORATOR,CONTRIBUTOR" to auto-review only known
contributors (others can still be reviewed on demand via
/orcacode-review). Also set a wallet budget + alert on the key.
Case-insensitive; values: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR,
FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE.
required: false
default: ""
max-diff-kb:
description: >-
Skip the review when the PR's merge-base diff is bigger than this many
KB. A skip posts a notice without running the engine; whether the check
then fails or passes is `on-oversized-diff` (default: fail, so the
merge gate cannot be padded around).
The default is a ceiling on WASTE, not on what the engine can handle.
It used to be 512, chosen to avoid spending tokens on diffs that review
badly — but it also meant a large PR could never be reviewed at all, and
"never" is worse than "expensive". The engine reads the diff through its
own tooling rather than taking it as one prompt, so size alone does not
decide whether it can work.
What DOES bound it is the model's context window, which the action cannot
see. Past that point a run reports a wall-clock timeout or an aborted
pass instead of a clean skip — louder, and no longer a silent refusal to
look. Lower this if you would rather have the clean skip back.
required: false
default: "5000"
max-diff-files:
description: >-
Skip the review when the diff touches more than this many files (same
notice + on-oversized-diff outcome as max-diff-kb). Counted separately
from size because the two describe different shapes: a mechanical rename
across 900 files is small and shallow, one 4 MB generated file is large
and shallow, and either can be worth reviewing.
required: false
default: "2000"
on-oversized-diff:
description: >-
What an oversized-diff skip does to the check. "fail" (default) fails
it — a diff padded past max-diff-kb / max-diff-files can then never
bypass a required merge gate. "pass" restores the advisory behavior:
the skip notice posts and the check stays green.
required: false
default: "fail"
settings:
description: >-
Fetch per-repo review settings from the OrcaRouter dashboard at the
start of each run. Set "false" to skip the fetch entirely and make this
workflow file authoritative: inputs (or their documented defaults)
apply as-is and no dashboard value can override them.
required: false
default: "true"
report:
description: >-
Send a best-effort per-tier run summary (repo, PR number, head SHA, tier,
P0/P1/P2 counts, gate result, engine version — never code or finding
text) to the OrcaRouter control plane. Set "false" to disable.
required: false
default: "true"
engine-version:
description: "Pinned @alibaba-group/open-code-review version (the review engine). Bump deliberately after testing — the later steps parse its JSON output shape."
required: false
default: "1.3.13"
timeout-minutes:
description: >-
Wall-clock ceiling (in minutes) for ONE engine review pass. If the engine
hasn't produced a result within this window it is killed and the run
fails closed with a distinct "wall-clock timeout" error (separate from
"no usable result", so the log makes clear which mode failed). Accepts
decimals (e.g. "0.5" = 30 seconds) for testing.
PER PASS, which matters more now that the default is 60: `exhaustive`
mode makes up to three passes, so the worst case is three times this
number. The default rose with the diff limits — a review allowed to
accept a much larger diff needs the time to actually read it, and 20
minutes would have converted "too big, skipped cleanly" into "killed
halfway", which is the same non-review with a worse message.
required: false
default: "60"
precision-filter:
description: >-
Post-processing between the engine and the merge gate. When `"true"`
(default), a deterministic filter (L1) verifies each finding's
`existing_code` snippet against the reviewed commit and re-homes or
drops findings whose snippet does not match the claimed path; then an
LLM judge (L2) clusters findings by root cause and drops
low-confidence ones. Set to `"false"` to post the engine's raw
findings directly — that skips BOTH layers, and is the supported way to
review without a judge.
L1 is soft-fail: if it errors the engine's findings carry forward. L2 is
NOT. L1's output is not publishable on its own, so a judge still
unavailable after retries fails the run closed rather than posting
unjudged findings.
required: false
default: "true"
judge-model:
description: >-
Model used by the L2 judge stage. Should differ from the reviewer
model so the judge acts as an independent second opinion. Ignored when
`precision-filter` is `"false"`.
required: false
default: ""
judge-threshold:
description: >-
Keep-threshold for the L2 judge's per-cluster confidence score (0–1).
Findings with confidence below this are dropped. Lower to keep more;
raise to be stricter.
required: false
default: "0.5"
concurrency:
description: >-
Max concurrent file reviews. Raise to shorten wall clock on large diffs;
lower it if your model has a tight per-minute request quota. Empty uses
the engine default.
required: false
default: "24"
max-tools:
description: >-
Max tool-call rounds per file. Lowering it reduces cost and time but can
cost review depth. Empty uses the engine default.
required: false
default: ""
meter:
description: >-
Record per-call token accounting for the run (prompt / completion /
cached tokens and the model the router resolved) and print a totals table
in the job log, so a run's real token cost is visible. Local only —
nothing is uploaded, and the file is removed with the other run files.
Soft-fail: metering never alters or blocks a review. Set "false" to skip
it.
required: false
default: "true"
runs:
using: "composite"
steps:
# FIRST, before anything can read $RUNNER_TEMP: a reused self-hosted
# runner can carry a PREVIOUS run's files (a hard cancel can kill a job
# before its always() cleanup step gets to run). Clear everything this
# action writes there — above all policy-block.json, which the
# always()-running "Surface guardrail / firewall block" step trusts and
# would otherwise post as a false "merge is blocked" comment on a clean
# run whose review step (and its own stale-file rm) never executed.
- name: Clear stale run files
shell: bash
run: |
# result-cheap/result-strong are the pre-single-tier names, kept here on
# purpose: a reused self-hosted runner hard-cancelled under the older
# action still has them, and they hold finding text and source snippets.
# This step exists for exactly the files an earlier cleanup never
# reached, so renaming them out of the list would strand them.
rm -f "$RUNNER_TEMP/result.json" "$RUNNER_TEMP/review.log" \
"$RUNNER_TEMP/result-final.json" "$RUNNER_TEMP/result-reported.json" \
"$RUNNER_TEMP/result-cheap.json" "$RUNNER_TEMP/result-strong.json" \
"$RUNNER_TEMP/result-posted.json" "$RUNNER_TEMP/result-extra.json" \
"$RUNNER_TEMP/result-merged.json" \
"$RUNNER_TEMP/cr-settings.json" "$RUNNER_TEMP/cr-rubric.md" \
"$RUNNER_TEMP/cr-facts.json" "$RUNNER_TEMP/proxy.out" "$RUNNER_TEMP/proxy.err" \
"$RUNNER_TEMP/policy-block.json" \
"$RUNNER_TEMP/pr.diff" "$RUNNER_TEMP/diff-guard.json" "$RUNNER_TEMP/prev-summary.md" \
"$RUNNER_TEMP/wallclock-timeout" "$RUNNER_TEMP/judge-unavailable" \
"$RUNNER_TEMP/cr-usage.jsonl" \
"$RUNNER_TEMP/cr-clean" \
"$RUNNER_TEMP/result.l1.json" "$RUNNER_TEMP/result.l2.json" \
"$RUNNER_TEMP/result-extra.l1.json" "$RUNNER_TEMP/result-extra.l2.json" \
"$RUNNER_TEMP/result.l2.log" "$RUNNER_TEMP/result-extra.l2.log"
- name: Resolve PR refs
id: pr
uses: actions/github-script@v7
with:
github-token: ${{ inputs.github-token }}
script: |
let base, headSha, num, isDraft, authorAssoc;
if (context.eventName === 'pull_request_target') {
base = context.payload.pull_request.base.ref;
headSha = context.payload.pull_request.head.sha;
num = context.payload.pull_request.number;
isDraft = context.payload.pull_request.draft === true;
authorAssoc = context.payload.pull_request.author_association || 'NONE';
} else {
num = context.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner, repo: context.repo.repo, pull_number: num,
});
base = pr.base.ref; headSha = pr.head.sha;
isDraft = pr.draft === true;
authorAssoc = pr.author_association || 'NONE';
}
core.setOutput('base', base);
core.setOutput('head_sha', headSha);
core.setOutput('number', String(num));
// The project-conventions doc is only trusted when the PR targets the
// repository's DEFAULT branch. A PR base is author-chosen: a push-access
// contributor could create a branch holding a malicious AGENTS.md and
// target it, so "base" is not inherently protected — but the default
// branch is the canonical ref they cannot rewrite without an approved
// merge. (Full branch-protection checks need admin token scope and can
// 403, so default-branch is the robust proxy.)
core.setOutput('base_is_default', String(base === context.payload.repository.default_branch));
// Draft state feeds the settings gate: trigger=ready_for_review
// skips auto-reviews while the PR is still a draft.
core.setOutput('is_draft', String(isDraft));
// PR author's association gates auto-review spend on public repos:
// pull_request_target bypasses GitHub's fork-approval gate, so the
// `auto-review-authors` allowlist can restrict which associations
// trigger a paid auto-review (comment commands stay maintainer-gated
// in the workflow `if:`).
core.setOutput('author_association', authorAssoc);
# Lightweight 👀 acknowledgement so the requester sees the bot noticed the
# trigger. Static one-shot (no update or removal). Fires early — before any
# skip gate — so even a skipped run still visibly acknowledges receipt.
# Reaction outages are non-fatal: the review must never be blocked by a
# UI-affordance failure.
- name: React 👀 to acknowledge review request
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
EVENT_NAME: ${{ github.event_name }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
# THE SAME GATE AS THE CLEAR, and its absence here was a regression the
# last round introduced: gating only the removal left a PAT-backed run
# POSTing 👀 and never taking it off, which is worse than the state
# before any of this. Declining is only coherent if it covers both ends.
#
# Checked once here rather than inside react(), so a token that cannot be
# identified costs one API call, not two.
# ONLY AN EXPLICIT "User" DECLINES. `gh api user` needs a user-scoped
# token, and github-token defaults to ${{ github.token }} — an
# installation token, for which that endpoint returns no user. The old
# `|| echo "Bot"` fallback was meant to cover that and did not: the call
# can exit 0 with no `.type`, so ME_TYPE came back empty, `!= "Bot"` held,
# and the reaction was skipped for EVERY consumer on the default token —
# the case this exists to serve. Measured on a real run: "belongs to a
# user, not an app" while the reaction author was github-actions[bot].
#
# Inverted to fail towards acting: a PAT identifies itself as type User,
# and anything else — 403, empty, Bot — reacts.
ME_TYPE=$(gh api user --jq .type 2>/dev/null) || ME_TYPE=""
if [ "$ME_TYPE" = "User" ]; then
echo "github-token belongs to a user, not an app; not reacting"
exit 0
fi
# JUST REACT. This used to record the id the POST returned so the clear
# step could delete exactly it — first unconditionally, then only on a
# 201 — and each version needed a JSON parse that node is not yet
# available for at this point in the job. The last one was a greedy
# expression that picked the nested author id out of a compact response,
# so the DELETE would have targeted the wrong resource.
#
# The id existed to tell OUR reaction from the token owner's. That
# question is settled once, in the settle step, by declining to manage
# reactions at all when github-token is a user rather than an app — so
# there is nothing here to record, and nothing to parse.
#
# Two concurrent runs as the same app share one reaction, so the first to
# finish clears it while the second is still working. Accepted: a missing
# 👀 for a few minutes is cosmetic, and every mechanism that avoided it
# cost more correctness elsewhere than it bought.
react() {
if gh api -X POST "$1" -f content=eyes >/dev/null 2>&1; then
echo "reacted 👀 -> $1"
else
echo "warning: failed to react at $1 (non-fatal)"
fi
}
# Always react on the PR body (issue reactions endpoint — PRs are issues).
if [ -n "${PR_NUMBER:-}" ]; then
react "repos/$REPO/issues/$PR_NUMBER/reactions"
fi
# For a maintainer's /orcacode-review command, also react on that
# trigger comment so the operator sees direct feedback on THEIR comment.
if [ "$EVENT_NAME" = "issue_comment" ] && [ -n "${COMMENT_ID:-}" ]; then
react "repos/$REPO/issues/comments/$COMMENT_ID/reactions"
fi
# setup-node runs BEFORE checkout (it never reads the workspace) so the
# settings fetch below has node available even on a bare self-hosted runner.
- uses: actions/setup-node@v4
with:
node-version: "20"
# Dashboard-driven behavior, fetched from the gateway BEFORE anything is
# cloned or installed: a settings-skip should cost nothing. settings.mjs
# always exits 0 and falls back to the built-in defaults on any outage
# (fail-open — a settings hiccup must never kill reviews); this step's own
# parsing falls back the same way, so `decision` is always review|skip.
- name: Fetch review settings
id: settings
shell: bash
env:
SETTINGS_SCRIPT: ${{ github.action_path }}/scripts/settings.mjs
SETTINGS_ENABLED: ${{ inputs.settings }}
ORCAROUTER_URL: ${{ inputs.orcarouter-url }}
ORCAROUTER_API_KEY: ${{ inputs.orcarouter-api-key }}
REPO: ${{ github.repository }}
SETTINGS_JSON: ${{ runner.temp }}/cr-settings.json
RUBRIC_FILE: ${{ runner.temp }}/cr-rubric.md
INPUT_FIX_FIRST: ${{ inputs.fix-first }}
INPUT_BLOCK_ON: ${{ inputs.block-on }}
EVENT_NAME: ${{ github.event_name }}
IS_DRAFT: ${{ steps.pr.outputs.is_draft }}
AUTHOR_ASSOC: ${{ steps.pr.outputs.author_association }}
AUTO_REVIEW_AUTHORS: ${{ inputs.auto-review-authors }}
run: |
# The auto-event gate is defined UP FRONT — before the settings-disabled
# short-circuit — so BOTH the settings-disabled (workflow-file-
# authoritative) and settings-enabled (dashboard) paths share the exact
# same spend guards. The auto-review-authors allowlist in particular is a
# WORKFLOW-FILE input, not a dashboard value, so it must gate paid auto
# reviews on public repos REGARDLESS of whether the dashboard fetch ran.
# Spend guard for public repos: only auto-review PRs whose author
# association is in the allowlist. Factored into a function so it is
# unit-tested directly (settings.test.mjs extracts and runs this exact
# code). Both sides are space-trimmed the way the JS list parsers are,
# so "OWNER, MEMBER" still anchors on ",MEMBER,"; the comma wrapping
# keeps the match a whole-token one (MEMBER never matches inside
# FIRST_TIME_CONTRIBUTOR). An empty (or all-space) allowlist allows
# everyone (default, preserves behavior). echoes allow|deny.
author_allowed() {
local list assoc
list=$(printf '%s' "$1" | tr -d ' ')
assoc=$(printf '%s' "$2" | tr -d ' ')
if [ -z "$list" ]; then echo allow; return; fi
if printf '%s' ",$list," | grep -qi ",$assoc,"; then echo allow; else echo deny; fi
}
# Auto-event gate -> sets DECISION/REASON. Only AUTO runs are gated —
# any pull_request* event, so a hand-written plain `pull_request`
# workflow honors gating exactly like the documented
# `pull_request_target` one. An explicit `/orcacode-review` comment
# command (issue_comment) always proceeds — on-demand mode exists
# precisely for those, and a human asking for a review of a
# draft/disabled repo is deliberate. Shared by both settings paths so the
# author-allowlist spend guard holds whether or not the dashboard fetch
# ran. $1=auto_review $2=trigger $3=is_draft.
gate_decision() {
DECISION=review
REASON=""
case "$EVENT_NAME" in
pull_request*)
if [ "$1" = "false" ]; then
DECISION=skip; REASON="auto-review is disabled in the OrcaRouter dashboard"
elif [ "$2" = "on_demand" ]; then
DECISION=skip; REASON="on-demand mode"
elif [ "$2" = "ready_for_review" ] && [ "$3" = "true" ]; then
DECISION=skip; REASON="draft PR"
elif [ "$(author_allowed "$AUTO_REVIEW_AUTHORS" "$AUTHOR_ASSOC")" = deny ]; then
# Maintainer comment commands are unaffected (gated in the workflow `if:`).
DECISION=skip; REASON="author association $AUTHOR_ASSOC not in auto-review-authors"
fi
;;
esac
}
# `settings: "false"` makes the workflow FILE authoritative: skip the
# fetch entirely, so inputs (or their documented defaults) apply as-is
# and no dashboard value can override them. (With the fetch on, an
# input left AT its documented default defers to the server — see the
# precedence rule below — so e.g. a deliberate `block-on: "P0,P1"` pin
# could still be overridden from the dashboard.) auto_review/trigger are
# fixed on (true/every_push) in this mode, so gate_decision can only skip
# via the author allowlist — a fork author still can't spend on a public
# repo just because the dashboard fetch is off.
if [ "$SETTINGS_ENABLED" = "false" ]; then
echo "settings: disabled via the 'settings' input — the workflow file is authoritative."
gate_decision true every_push false
echo "settings: workflow-file-authoritative -> $DECISION ${REASON:+($REASON)}"
{
echo "auto_review=true"
echo "trigger=every_push"
echo "exhaustive=false"
echo "quiet=false"
echo "fix_first=$INPUT_FIX_FIRST"
echo "block_on=$INPUT_BLOCK_ON"
# Every severity: with the dashboard off there is no report_on to
# honour and the workflow file has no input for it, so display must
# not narrow on its own.
echo "report_on=P0,P1,P2,P3"
echo "rubric_file="
echo "decision=$DECISION"
echo "reason=$REASON"
} >> "$GITHUB_OUTPUT"
exit 0
fi
node "$SETTINGS_SCRIPT" --url "$ORCAROUTER_URL" \
--repo "$REPO" --out "$SETTINGS_JSON"
# Read one validated field from the settings file; $2 is the fallback
# if the file itself is missing/unreadable (settings.mjs already
# defaulted and validated every field it wrote).
setting() {
node -pe 'String(JSON.parse(require("fs").readFileSync(process.env.SETTINGS_JSON, "utf8"))[process.argv[1]])' \
"$1" 2>/dev/null || printf '%s' "$2"
}
AUTO_REVIEW=$(setting auto_review true)
TRIGGER=$(setting trigger every_push)
EXHAUSTIVE=$(setting exhaustive false)
QUIET=$(setting quiet false)
FIX_FIRST=$(setting fix_first "P0,P1")
BLOCK_ON=$(setting block_on "P0,P1")
# DISPLAY ONLY, and with no `with:` override on purpose: fix_first and
# block_on have workflow inputs because a repo may need to ENFORCE
# differently from the workspace default, whereas which severities a
# reader SEES is the workspace's call. Defaulting to every severity means
# a gateway that predates the field behaves exactly as before.
# Agrees with settings.mjs DEFAULTS on purpose. settings.mjs always writes
# a defaulted file, so this fires only if that file is missing or
# unreadable — and two different answers to the same question is how one
# of them ends up wrong without anyone noticing.
REPORT_ON=$(setting report_on "P0,P1")
RUBRIC=$(setting rubric "")
# PRECEDENCE (documented in the README): an explicit `with:` input that
# DIFFERS from the action's documented default ("P0,P1") wins over the
# server settings; an input left at the default defers to the server.
if [ "$INPUT_FIX_FIRST" != "P0,P1" ]; then FIX_FIRST="$INPUT_FIX_FIRST"; fi
if [ "$INPUT_BLOCK_ON" != "P0,P1" ]; then BLOCK_ON="$INPUT_BLOCK_ON"; fi
# A non-empty server rubric REPLACES rules/severity-instruction.md as
# the --background content; it is exposed as a file path only when set.
RUBRIC_OUT=""
if [ -n "$RUBRIC" ]; then
printf '%s\n' "$RUBRIC" > "$RUBRIC_FILE"
RUBRIC_OUT="$RUBRIC_FILE"
echo "Using the server-side rubric ($(wc -c < "$RUBRIC_FILE") bytes) instead of the built-in one."
fi
# Settings gate — composable with the diff guard's `decision != 'skip'`.
# Same gate_decision as the settings-disabled path (defined at the top of
# this step): the dashboard-fetched auto_review/trigger feed it here,
# while the author-allowlist spend guard inside it is identical in both
# paths.
gate_decision "$AUTO_REVIEW" "$TRIGGER" "$IS_DRAFT"
echo "settings: auto_review=$AUTO_REVIEW trigger=$TRIGGER exhaustive=$EXHAUSTIVE quiet=$QUIET fix_first=$FIX_FIRST block_on=$BLOCK_ON report_on=$REPORT_ON rubric=${RUBRIC_OUT:+custom} -> $DECISION ${REASON:+($REASON)}"
{
echo "auto_review=$AUTO_REVIEW"
echo "trigger=$TRIGGER"
echo "exhaustive=$EXHAUSTIVE"
echo "quiet=$QUIET"
echo "fix_first=$FIX_FIRST"
echo "block_on=$BLOCK_ON"
echo "report_on=$REPORT_ON"
echo "rubric_file=$RUBRIC_OUT"
echo "decision=$DECISION"
echo "reason=$REASON"
} >> "$GITHUB_OUTPUT"
# On a settings-skip: leave ONE small marker comment (created if absent,
# edited only when the reason changes — repeated pushes must not spam the
# timeline or its edit history), then PASS. No later step runs the engine
# or the gate, so the job (and a required check) goes green.
- name: Skip review (settings)
if: steps.settings.outputs.decision == 'skip'
uses: actions/github-script@v7
env:
BRAND: ${{ inputs.brand }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
REASON: ${{ steps.settings.outputs.reason }}
with:
github-token: ${{ inputs.github-token }}
script: |
const brand = process.env.BRAND;
const num = Number(process.env.PR_NUMBER);
const repo = { owner: context.repo.owner, repo: context.repo.repo };
// Own marker (distinct from the summary and oversized-skip ones).
const MARKER = '<!-- orca-code-review-disabled -->';
const footer =
`\n\n<sub>[OrcaCode Review](https://www.orcarouter.ai/code-review) — ` +
`Route Smarter. Ship Safer. Spend Less.</sub>`;
const body =
`${MARKER}\n## 🐳 ${brand}\n\n` +
`⏸️ **Automatic review is off for this PR** (${process.env.REASON}).\n\n` +
`The check **passes** — this never blocks your merge. A maintainer can still ` +
`run a review on demand by commenting \`/orcacode-review\` ` +
`(\`@orcacode review\` works too), or change the ` +
`repo's review settings in the OrcaRouter dashboard.` +
footer;
const comments = await github.paginate(github.rest.issues.listComments, {
...repo, issue_number: num, per_page: 100,
});
const existing = comments.find((c) => c.body && c.body.includes(MARKER));
if (!existing) {
await github.rest.issues.createComment({ ...repo, issue_number: num, body });
} else if (existing.body !== body) {
// Only when the reason changed (e.g. on_demand -> draft).
await github.rest.issues.updateComment({ ...repo, comment_id: existing.id, body });
}
await core.summary.addRaw(`skipped: ${process.env.REASON}`).write();
core.notice(`${brand}: review skipped — ${process.env.REASON}`);
- uses: actions/checkout@v4
if: steps.settings.outputs.decision != 'skip'
with:
fetch-depth: 0 # full history for the merge-base diff
ref: ${{ steps.pr.outputs.head_sha }}
# Don't leave the job token in .git/config: on pull_request_target later
# steps (incl. the OCR agent reading untrusted PR files) run with secrets,
# so a prompt-injected file must not be able to read a persisted token.
persist-credentials: false
- name: Fetch PR head (covers fork PRs)
if: steps.settings.outputs.decision != 'skip'
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
run: |
# Authenticate this one fetch with a command-scoped header (works for
# private repos) so the token is never written to disk for later steps.
AUTH=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')
git -c "http.extraheader=Authorization: Basic $AUTH" \
fetch origin "pull/${PR_NUMBER}/head"
- name: Oversized-diff guard
id: guard
if: steps.settings.outputs.decision != 'skip'
shell: bash
env:
BRAND: ${{ inputs.brand }}
BASE: origin/${{ steps.pr.outputs.base }}
HEAD: ${{ steps.pr.outputs.head_sha }}
GUARD: ${{ github.action_path }}/scripts/diff-guard.mjs
MAX_KB: ${{ inputs.max-diff-kb }}
MAX_FILES: ${{ inputs.max-diff-files }}
DIFF_FILE: ${{ runner.temp }}/pr.diff
GUARD_JSON: ${{ runner.temp }}/diff-guard.json
run: |
# Size-check the SAME range the engine reviews (merge-base diff),
# BEFORE anything is installed or run: an oversized PR should cost
# nothing. `--merge-base` needs git >= 2.30; fall back to the
# equivalent three-dot form, then fail OPEN (empty file -> "review")
# if no diff can be produced — a guard glitch must never kill the job.
git diff --merge-base "$BASE" "$HEAD" > "$DIFF_FILE" 2>/dev/null \
|| git diff "$BASE...$HEAD" > "$DIFF_FILE" 2>/dev/null \
|| { echo "::warning::$BRAND: diff generation failed — size guard failing open."; : > "$DIFF_FILE"; }
node "$GUARD" --diff "$DIFF_FILE" --max-kb "$MAX_KB" --max-files "$MAX_FILES" > "$GUARD_JSON"
cat "$GUARD_JSON"
node -e '
const fs = require("fs");
const g = JSON.parse(fs.readFileSync(process.env.GUARD_JSON, "utf8"));
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`decision=${g.decision}\nreason=${g.reason}\nsize_kb=${g.size_kb}\nfiles=${g.files}\n`,
);
'
# On "skip": explain on the PR (upserted — one notice, edited while pushes
# stay oversized), then apply `on-oversized-diff`. No later step runs the
# engine or the gate either way. Default "fail": the check goes RED — an
# unreviewed diff must not satisfy a required merge gate (padding a PR
# past max-diff-kb would otherwise bypass the P0/P1 gate entirely).
# "pass" restores the advisory behavior (notice + green check).
- name: Skip review (diff too large)
if: steps.guard.outputs.decision == 'skip'
uses: actions/github-script@v7
env:
BRAND: ${{ inputs.brand }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
SIZE_KB: ${{ steps.guard.outputs.size_kb }}
FILES: ${{ steps.guard.outputs.files }}
REASON: ${{ steps.guard.outputs.reason }}
MAX_KB: ${{ inputs.max-diff-kb }}
MAX_FILES: ${{ inputs.max-diff-files }}
ON_OVERSIZED: ${{ inputs.on-oversized-diff }}
with:
github-token: ${{ inputs.github-token }}
script: |
const brand = process.env.BRAND;
const num = Number(process.env.PR_NUMBER);
const repo = { owner: context.repo.owner, repo: context.repo.repo };
// Anything that is not an explicit "pass" fails the check (fail-safe).
const pass = process.env.ON_OVERSIZED === 'pass';
// Own marker (distinct from the summary comment's): once the PR
// shrinks and reviews resume, the summary comment takes over (and
// deletes this notice).
const MARKER = '<!-- orca-code-review-skip -->';
const footer =
`\n\n<sub>[OrcaCode Review](https://www.orcarouter.ai/code-review) — ` +
`Route Smarter. Ship Safer. Spend Less.</sub>`;
const outcome = pass
? `and the check **passes** (\`on-oversized-diff: "pass"\`), so this skip does not block your merge.`
: `and the check **fails** (\`on-oversized-diff: "fail"\`, the default), so an unreviewed ` +
`diff cannot slip through a required merge gate.`;
const body =
`${MARKER}\n## 🐳 ${brand}\n\n` +
`⏭️ **Review skipped — this PR's diff is too large** (${process.env.REASON}).\n\n` +
`| | This PR | Limit |\n|---|---|---|\n` +
`| Diff size | ${process.env.SIZE_KB} KB | ${process.env.MAX_KB} KB |\n` +
`| Files touched | ${process.env.FILES} | ${process.env.MAX_FILES} |\n\n` +
`The review engine was **not** run — reviews of very large diffs truncate and ` +
`lose signal — ${outcome}\n\n` +
`**What now?** Split the PR (smaller diffs get much higher-signal reviews), raise ` +
`the limits, or make skips advisory:\n\n` +
'```yaml\nwith:\n max-diff-kb: "10000" # default 5000\n max-diff-files: "4000" # default 2000\n on-oversized-diff: "pass" # default "fail"\n```' +
footer;
const comments = await github.paginate(github.rest.issues.listComments, {
...repo, issue_number: num, per_page: 100,
});
const existing = comments.find((c) => c.body && c.body.includes(MARKER));
if (existing) {
await github.rest.issues.updateComment({ ...repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ ...repo, issue_number: num, body });
}
await core.summary.addRaw(`skipped: diff too large (${process.env.REASON})`).write();
if (pass) {
core.notice(`${brand}: review skipped — ${process.env.REASON}`);
} else {
core.setFailed(
`${brand}: review skipped — ${process.env.REASON}. Failing the check so the merge ` +
`gate cannot be bypassed by an oversized diff: raise max-diff-kb / max-diff-files, ` +
`split the PR, or set on-oversized-diff: "pass" to make skips advisory.`,
);
}
- name: Install review engine
if: steps.settings.outputs.decision != 'skip' && steps.guard.outputs.decision != 'skip'
shell: bash
env:
ENGINE_VERSION: ${{ inputs.engine-version }}
run: npm install -g "@alibaba-group/open-code-review@${ENGINE_VERSION}"
- name: Review
id: review
if: steps.settings.outputs.decision != 'skip' && steps.guard.outputs.decision != 'skip'
shell: bash
env:
ROUTER: ${{ inputs.router }}
# Wall-clock ceiling for ONE engine pass; wrapped around `ocr review`
# via GNU `timeout` inside run_pass. Passed as-is (units are minutes;
# decimals accepted, e.g. "0.5" = 30s). See the input docstring.
TIMEOUT_MIN: ${{ inputs.timeout-minutes }}
# Precision post-processing (L1 postfilter + L2 LLM judge) sits
# between `ocr review` and check-result.mjs; toggled by the input.
PRECISION_FILTER: ${{ inputs.precision-filter }}
POSTFILTER: ${{ github.action_path }}/scripts/postfilter.mjs
JUDGE: ${{ github.action_path }}/scripts/judge.mjs
JUDGE_MODEL: ${{ inputs.judge-model }}
JUDGE_THRESHOLD: ${{ inputs.judge-threshold }}
# Effective value after the settings/input precedence rule.
FIX_FIRST: ${{ steps.settings.outputs.fix_first }}
# Non-empty only when the dashboard supplies a replacement rubric.
RUBRIC_FILE: ${{ steps.settings.outputs.rubric_file }}
EXHAUSTIVE: ${{ steps.settings.outputs.exhaustive }}
MERGE: ${{ github.action_path }}/scripts/exhaustive-merge.mjs
BASE: origin/${{ steps.pr.outputs.base }}
HEAD: ${{ steps.pr.outputs.head_sha }}
INSTRUCTION: ${{ github.action_path }}/rules/severity-instruction.md
OUTPUT_SHAPE: ${{ github.action_path }}/rules/output-shape.md
# Untrusted-data framing prepended to the project-conventions doc that
# we extract from the BASE revision and inline into the background file
# (see the review step): tells the engine the doc may only suppress
# style findings, never weaken correctness/security or alter severity.
CONVENTIONS_DIRECTIVE: ${{ github.action_path }}/rules/conventions-directive.md
# Only load conventions when the PR targets the default branch (base is
# then a protected ref the PR author cannot rewrite). See the pr step.
BASE_IS_DEFAULT: ${{ steps.pr.outputs.base_is_default }}
GATE: ${{ github.action_path }}/scripts/gate.mjs
CHECK: ${{ github.action_path }}/scripts/check-result.mjs
PROXY: ${{ github.action_path }}/scripts/fact-proxy.mjs
BRAND: ${{ inputs.brand }}
# The real OrcaRouter endpoint. OCR does NOT talk to it directly: the
# in-job proxy stamps the routing facts as headers and forwards here.
ORCAROUTER_URL: ${{ inputs.orcarouter-url }}
# Drive OCR entirely through env vars — its highest-priority config source.
# Nothing is written to the shared ~/.opencodereview, so we neither leak a
# token to disk nor clobber a self-hosted runner's existing OCR config.
# The model is the router alias; the router's DSL picks the real model.
OCR_LLM_TOKEN: ${{ inputs.orcarouter-api-key }}
OCR_LLM_AUTH_HEADER: authorization
OCR_LLM_MODEL: ${{ inputs.router }}
OCR_USE_ANTHROPIC: "false"
# Pin the engine: a background auto-update would defeat engine-version and
# could change the JSON shape the later steps parse.
OCR_NO_UPDATE: "1"
# Write engine output OUTSIDE the PR checkout. A fork could commit
# `result.json`/`review.log` as symlinks pointing at e.g. ~/.ssh; a `>`
# redirect in the working tree would follow them and clobber the target.
# $RUNNER_TEMP is runner-controlled and reset per job.
RESULT: ${{ runner.temp }}/result.json
REVIEW_LOG: ${{ runner.temp }}/review.log
# An unfiltered snapshot of $RESULT for the run report: it has to carry
# what the review FOUND, and $RESULT itself is the input to the display
# chain (report_on then quiet) further down.
RESULT_FINAL: ${{ runner.temp }}/result-final.json
# Exhaustive-mode scratch: each extra engine pass lands in
# $RESULT_EXTRA and is deduped into $RESULT via $RESULT_MERGED.
RESULT_EXTRA: ${{ runner.temp }}/result-extra.json
RESULT_MERGED: ${{ runner.temp }}/result-merged.json
FACTS_FILE: ${{ runner.temp }}/cr-facts.json
# Per-call token accounting (see the `meter` input). Empty disables the
# tap entirely inside the proxy.
CONCURRENCY: ${{ inputs.concurrency }}
MAX_TOOLS: ${{ inputs.max-tools }}
METER: ${{ inputs.meter }}
USAGE_FILE: ${{ runner.temp }}/cr-usage.jsonl
# The proxy writes the guardrail/firewall reason here on a block; a later
# always()-step posts it to the PR (the review step itself fails closed).
POLICY_BLOCK: ${{ runner.temp }}/policy-block.json
run: |
set -e
# Security: we run with secrets against the PR head on pull_request_target,
# so never let PR-controlled config weaken the review. A fork could commit
# `.opencodereview/rule.json` to exclude its own changed files or replace
# the built-in checks, yielding an empty result that passes as
# clean. Drop it so OCR uses system defaults + our --background instruction.
rm -rf .opencodereview
# Clear any stale block file before the proxy can write a fresh one. On a
# self-hosted runner $RUNNER_TEMP may be reused, and the always()-step
# below trusts whatever is here — a leftover block from an earlier run
# would post a false "merge is blocked" comment on this clean run.
rm -f "$POLICY_BLOCK"
# Start the in-job fact-injecting proxy (loopback, ephemeral). OCR can't
# send custom headers; the proxy stamps x-cr-* facts (from $FACTS_FILE,
# re-read per request) so the router's DSL can route on them.
# Pre-create the output files: the backgrounded node opens proxy.out
# asynchronously, and under `set -e` a first-iteration `sed` on a
# not-yet-created file would kill the whole step with no diagnostics.
echo '{}' > "$FACTS_FILE"
: > "$RUNNER_TEMP/proxy.out"
: > "$RUNNER_TEMP/proxy.err"
# Metering is opt-out; an empty CR_USAGE_FILE turns the tap off in the proxy.
CR_USAGE_FILE=""
if [ "$METER" = "true" ]; then CR_USAGE_FILE="$USAGE_FILE"; fi
export CR_USAGE_FILE
CR_FACTS_FILE="$FACTS_FILE" CR_POLICY_BLOCK_FILE="$POLICY_BLOCK" node "$PROXY" > "$RUNNER_TEMP/proxy.out" 2> "$RUNNER_TEMP/proxy.err" &
PROXY_PID=$!
trap 'kill "$PROXY_PID" 2>/dev/null || true' EXIT
for _ in $(seq 1 50); do
OCR_LLM_URL=$(sed -n 's/^PROXY_URL=//p' "$RUNNER_TEMP/proxy.out" 2>/dev/null || true)
[ -n "$OCR_LLM_URL" ] && break
sleep 0.1
done
if [ -z "$OCR_LLM_URL" ]; then
echo "::error::$BRAND: fact proxy failed to start."; cat "$RUNNER_TEMP/proxy.err" || true; exit 1
fi
export OCR_LLM_URL
# Set the raw facts the DSL routes on for the NEXT review pass.
# $1 = prev-tier fact, $2 = prev pass found a fix-first (true|false)
set_facts() {
printf '{"x-cr-prev-tier":"%s","x-cr-prev-p0p1":"%s"}' "$1" "$2" > "$FACTS_FILE"
}
# The review background is assembled into a FILE, then passed inline via
# --background "$(cat …)" (the pinned engine 1.3.13 has no
# --background-file flag). On Linux the arg comfortably holds our sizes
# (severity rubric ~6 KB + a typical AGENTS.md). Two parts:
# 1. Severity rubric + [P0]-[P3] tag mandate (rules/severity-instruction.md,
# or the dashboard's server-side override, which REPLACES it wholesale;
# the untagged->P1 fail-safe still catches slippage). This rides the
# background, NOT --rule: a --rule glob REPLACES OCR's built-in
# language/security checklist for matched files, and the rubric is
# explicitly ADDITIVE on top of that checklist — moving it to --rule
# would delete the "what to look for" the rubric depends on.
# 2. Project conventions, read from the BASE revision — never the PR
# head (the engine's file tools can only read the head version, so
# we extract the doc ourselves) — and ONLY when the base is the
# repo's default branch: a PR base is author-chosen, so an untrusted
# base could itself be attacker-controlled. Inlined below, framed as
# untrusted data that may only SUPPRESS style findings, and capped so
# a large doc cannot push the inlined arg past the OS argv limit.
BACKGROUND="$RUNNER_TEMP/cr-background.md"
if [ -n "$RUBRIC_FILE" ] && [ -s "$RUBRIC_FILE" ]; then
cp "$RUBRIC_FILE" "$BACKGROUND"
echo "Severity rubric: using the dashboard's server-side override."
else
cp "$INSTRUCTION" "$BACKGROUND"
fi
# APPENDED IN BOTH BRANCHES, because it is not part of the rubric. The
# rubric decides WHICH severity a finding gets and a workspace may
# replace it wholesale; the output SHAPE is how every finding is written
# down, and the renderer splits a title from a body on it. Living inside
# the replaceable file, the shape contract reached only workspaces that
# had not customised their rubric — the ones least likely to notice the
# untitled output it exists to prevent.
if [ -s "$OUTPUT_SHAPE" ]; then
echo "" >> "$BACKGROUND"
echo "" >> "$BACKGROUND"
cat "$OUTPUT_SHAPE" >> "$BACKGROUND"
fi
# Append the first conventions doc found at the base revision. $BASE is
# origin/<base ref>; when it is the default branch it is a protected ref
# the PR author cannot rewrite, so `git show "$BASE:<path>"` is trusted.
CONVENTIONS_MAX_BYTES=32768
if [ "$BASE_IS_DEFAULT" = "true" ] && [ -s "$CONVENTIONS_DIRECTIVE" ]; then
for f in AGENTS.md CLAUDE.md CONTRIBUTING.md; do
if doc=$(git show "$BASE:$f" 2>/dev/null | head -c "$CONVENTIONS_MAX_BYTES") && [ -n "$doc" ]; then
{
printf '\n\n'
cat "$CONVENTIONS_DIRECTIVE"
printf '\n\n----- BEGIN %s (untrusted project conventions; read-only reference) -----\n' "$f"
printf '%s\n' "$doc"
printf -- '----- END %s -----\n' "$f"
} >> "$BACKGROUND"
echo "Project conventions: inlined $f from the base revision ($BASE)."
break
fi
done
fi
# One engine pass into $1; returns nonzero on an unusable/partial
# result. The CALLER decides what that means: a tier's FIRST pass
# fails the job closed, an exhaustive EXTRA pass just ends the loop
# (extra depth is best-effort — findings already in hand must survive).
# $1 = output file, $2 = log label, $3 = prev tier fact, $4 = prev fix-first fact
run_pass() {
set_facts "$3" "$4"
echo "::group::Review — $2"
rc=0
# Wall-clock guard: SIGTERM at TIMEOUT_MIN; SIGKILL 10s later if the
# engine ignored the term. Redirections apply to whatever `timeout`
# runs — i.e. ocr's own stdout/stderr — so the JSON result and log
# end up in the same files as before.
# Engine tuning flags are only passed when set, so an empty input
# leaves the engine's own default in place rather than forcing a value.
ENGINE_FLAGS=""
[ -n "$CONCURRENCY" ] && ENGINE_FLAGS="$ENGINE_FLAGS --concurrency $CONCURRENCY"
[ -n "$MAX_TOOLS" ] && ENGINE_FLAGS="$ENGINE_FLAGS --max-tools $MAX_TOOLS"
# shellcheck disable=SC2086
timeout --kill-after=10s "${TIMEOUT_MIN}m" \
ocr review --from "$BASE" --to "$HEAD" \
--background "$(cat "$BACKGROUND")" --format json $ENGINE_FLAGS \
> "$1" 2> "$REVIEW_LOG" || rc=$?
cat "$REVIEW_LOG"
# 124 = GNU timeout tripped (SIGTERM); 137 = 128+9, i.e. --kill-after
# escalated to SIGKILL. Leave a marker so run_review can print a
# wall-clock-specific error (distinct from "no usable result").
if [ "$rc" = "124" ] || [ "$rc" = "137" ]; then
echo "::warning::Engine wall-clock timeout after ${TIMEOUT_MIN} minute(s) (rc=$rc)."
printf 'wall-clock timeout after %sm\n' "$TIMEOUT_MIN" > "$RUNNER_TEMP/wallclock-timeout"
fi
echo "::endgroup::"
# Precision post-processing (seam ③). Only runs when the engine
# produced a usable JSON result on this pass — a timeout or crash
# leaves nothing to filter, so we skip straight to CHECK which
# will fail closed on rc anyway. Both layers write to a temp file
# first and only overwrite $1 on success, so no postprocessing error
# can corrupt the engine output.
#
# L1 is fail-safe in the full sense: an error there keeps the engine's
# findings and the review continues. L2 is not — see the judge branch
# below for why an unavailable judge has to fail the pass.
#
# A PARTIAL RESULT IS ALREADY DOOMED, so do not spend the filter on
# it. CHECK fails closed on any non-empty `warnings`, so a pass that
# carries them cannot be published however the judge rules. Filtering
# anyway buys a judge call for output nobody will read — and if THAT
# call is what fails, the summary blames "the L2 judge was
# unavailable" for a run whose actual defect was a partial engine
# result, the exact mis-attribution the reason= tags were added to
# end. Falling through to CHECK lets reason=partial name it, and
# name the warnings with it.
#
# `warnings` IS NORMALIZED THE WAY CHECK NORMALIZES IT — Array.isArray
# or nothing (check-result.mjs:44). The two predicates decide the fate
# of the same field and must not disagree: a non-array `warnings`, say
# the string "oops", has a nonzero .length, so a bare length test reads
# it as partial and skips the filter, while CHECK reads it as absent and
# publishes. That combination ships the engine's raw findings with no
# L1 and no L2 under `precision-filter: true` — the exact thing this
# pass fails closed to prevent, arriving as a green review.
#
# Which fixes the direction this test has to fail in: SKIPPING is the
# branch that can publish unjudged, so it may only be taken when the
# result is definitely partial. Anything malformed or unrecognized runs
# the filter.
JUDGE_FAILED=""
if [ "$PRECISION_FILTER" = "true" ] && [ "$rc" = "0" ] && [ -s "$1" ] \
&& node -e "const r=require(process.argv[1]);const w=Array.isArray(r.warnings)?r.warnings:[];process.exit((r.comments||[]).length>0&&w.length===0?0:1)" "$1" 2>/dev/null; then
echo "::group::Precision filter — $2"
L1_OUT="${1%.json}.l1.json"
if node "$POSTFILTER" "$1" "$GITHUB_WORKSPACE" "$HEAD" --out "$L1_OUT" 2>&1 \
&& [ -s "$L1_OUT" ]; then
L1_IN_COUNT=$(node -pe "(require('$1').comments||[]).length")
L1_OUT_COUNT=$(node -pe "(require('$L1_OUT').comments||[]).length")
echo "L1 postfilter: $L1_IN_COUNT -> $L1_OUT_COUNT"
mv "$L1_OUT" "$1"
else
echo "::warning::L1 postfilter failed — keeping engine findings"
fi
L2_OUT="${1%.json}.l2.json"
# Snapshot the engine's policy-block CONTENT (not just presence)
# before the L2 sidecar. The judge call goes through fact-proxy,
# whose `recordPolicyBlock()` can OVERWRITE `$POLICY_BLOCK` if the
# judge itself hits a guardrail — a presence-only flag can't tell
# "engine block preserved" from "engine block silently replaced by
# L2 block". After the L2 call, if we snapshotted engine content
# we always restore it (both success and fail paths) so the
# engine's guardrail record wins over an L2 sidecar overwrite.
POLICY_BLOCK_SNAPSHOT="${POLICY_BLOCK}.pre-l2"
rm -f "$POLICY_BLOCK_SNAPSHOT"
if [ -f "$POLICY_BLOCK" ]; then cp -f "$POLICY_BLOCK" "$POLICY_BLOCK_SNAPSHOT"; fi
# THE ROUTER DECIDES, unless judge-model overrides it. Its default is now
# empty, which sends this router's ALIAS instead of a model name; judge.mjs