-
Notifications
You must be signed in to change notification settings - Fork 3k
Expand file tree
/
Copy pathaction.yml
More file actions
1094 lines (1057 loc) · 54.2 KB
/
Copy pathaction.yml
File metadata and controls
1094 lines (1057 loc) · 54.2 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: OpenCodeReview PR Review
description: >-
AI-powered GitHub PR review with inline comments, sticky summary, and
incremental non-destructive posting.
author: alibaba
branding:
icon: eye
color: green
inputs:
llm_url:
description: LLM API endpoint URL (mapped to env OCR_LLM_URL).
required: true
llm_auth_token:
description: LLM auth token (mapped to env OCR_LLM_TOKEN).
required: true
llm_model:
description: Model name (mapped to env OCR_LLM_MODEL).
required: true
llm_use_anthropic:
description: >-
Selects the LLM protocol (mapped to env OCR_USE_ANTHROPIC). An explicitly supplied empty
string, true, 1, or yes selects Anthropic
case-insensitively; every other value selects the OpenAI-compatible
protocol, preserving the CLI environment contract. Ignored when
llm_protocol names the protocol explicitly.
required: true
llm_protocol:
description: >-
Explicit LLM protocol written to `llm.protocol`: anthropic, openai or
openai-responses (case-insensitive). When set it wins over
llm_use_anthropic, which is mirrored from it the way `ocr config set
llm.protocol` mirrors the boolean. Leave empty to keep deriving the
protocol from llm_use_anthropic; an OCR_LLM_PROTOCOL variable in the
job or step environment is honoured the same way when this is empty.
required: false
llm_auth_header:
description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER).
required: false
llm_extra_headers:
description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS).
required: false
llm_extra_body:
description: >-
extra_body JSON for LLM requests. No env var exists for this, so it is
written via `ocr config set llm.extra_body`. The default disables
thinking mode for compatibility with various LLM providers; override it
with provider-specific JSON when a model needs different behavior. An
explicit reasoning_effort key here wins over the llm_reasoning_effort
input.
required: false
default: '{"thinking": {"type": "disabled"}}'
llm_reasoning_effort:
description: >-
Reasoning depth for the model (one of: minimal, low, medium, high, max;
case-insensitive), merged into the request body as reasoning_effort via
llm.extra_body — no CLI support beyond the published versions is needed.
OpenAI-compatible protocols only (e.g. GLM-5.x, OpenAI reasoning
models); the Anthropic API rejects unknown body fields, so the action
fails fast when this is set on the Anthropic protocol — steer Anthropic
thinking through an explicit llm_extra_body key instead. Empty
(default) sends nothing. An explicit reasoning_effort key in
llm_extra_body wins over this input.
required: false
default: ''
language:
description: >-
Review output language, written via `ocr config set language`
(e.g. English, Chinese). No env var exists for this.
required: false
default: 'English'
llm_timeout:
description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT).
required: false
default: '300'
review_task_timeout:
description: Per-file/concurrent-task timeout in integer minutes, from 1 through 120.
required: false
default: '15'
github_token:
description: GitHub token used to post review comments.
required: false
default: ${{ github.token }}
ocr_version:
description: npm version spec for @alibaba-group/open-code-review (v1.9.6 or newer required).
required: false
default: latest
review_concurrency:
description: Value passed to `ocr review --concurrency`.
required: false
background:
description: Value passed to `ocr review --background`.
required: false
rule:
description: Path to a custom rules JSON file passed to `ocr review --rule`.
required: false
effort:
description: >-
Review effort preset passed to `ocr review --effort`. One of: low,
medium, high (case-insensitive). Empty keeps the CLI default (the
configured value, or medium). Requires OpenCodeReview v1.10.0 or newer.
required: false
default: ''
max_tokens_budget:
description: >-
Total token cap passed to `ocr review --max-tokens-budget`. Base-10
integer; empty or 0 means unlimited. Checked before every LLM round:
a group already over the cap gets one final round to submit findings,
no further groups are dispatched, over-budget and skipped files are
reported as failed(budget), partial results are still published, and
the review exits 0.
required: false
default: ''
stream_progress:
description: >-
Stream live [ocr] review progress to the workflow log (human audience
on stderr) instead of staying silent until the run finishes. One of:
true, false (case-insensitive); empty falls back to false. false
(default) keeps the silent agent-audience run with stderr captured to
a log file. Requires OpenCodeReview v1.9.8 or newer when enabled.
required: false
default: 'false'
upload_artifacts:
description: >-
Upload raw JSON result and stderr as workflow artifacts. Must be the
literal string 'true' or 'false' (quoted); the step gates on a string
comparison, so an unquoted YAML boolean will not match.
required: false
default: 'true'
sticky_summary:
description: >-
Summary dimension. true = update an existing summary comment in place
(sticky) instead of posting a new one each run.
required: false
default: 'true'
incremental:
description: >-
Incremental dimension. true = only append inline comments whose (path,
line range) does not overlap an existing bot review comment. History is
never deleted (non-destructive).
required: false
default: 'false'
incremental_overlap_threshold:
description: >-
IoU (intersection-over-union) threshold used by incremental mode to decide
whether a new multi-line comment overlaps an existing one. Two single-line
comments match when on the same line; single- vs multi-line never match.
Value in (0, 1]. Read only when incremental is true: resolve_outdated
decides overlap by any shared line, with no threshold, because a missed
overlap there resolves a thread whose finding is still live.
required: false
default: '0.6'
resolve_outdated:
description: >-
Resolve the action's own outdated inline review threads (threads GitHub
has already marked outdated because their lines no longer exist in the
diff). Threads a human replied to, threads already resolved, and threads
whose lines a finding from the current run still covers are never touched.
One of: 'false' (default, does nothing and makes no API calls), 'report'
(log what would be resolved, change nothing — run this first), 'true'
(actually resolve). 'true' requires the calling workflow to grant
`contents: write` in its own `permissions:` block: GitHub gates the
resolve mutation on repository write access, not on the pull-request
scope, so `pull-requests: write` alone returns FORBIDDEN. Without it the
action warns and continues. 'report' issues no mutation, so it cannot
tell you in advance whether 'true' would be permitted.
A thread is covered when a current finding shares ANY line with it; the
incremental_overlap_threshold IoU test is not used here.
required: false
default: 'false'
review_comment_batch_size:
description: >-
Maximum number of inline comments packed into a single createReview call.
Large reviews are split into sequential batches of at most this size so a
single request never exceeds GitHub's practical limits (a prior run failed
after posting 71 inline comments in one request). Integer >= 1; values
below 1 or non-numeric fall back to the default (50).
required: false
default: '50'
route_severity_below:
description: >-
Optional severity threshold that routes findings at-or-below it from
inline comments to the PR summary (fail-open: never drops a finding).
One of: critical, high, medium, low. For example, 'low' routes only
low-severity findings, while 'medium' routes medium AND low. An empty or
unknown value disables severity routing (findings stay inline). Findings
with an empty or unknown severity are never routed by this policy and
keep their normal placement.
required: false
default: ''
route_categories:
description: >-
Optional comma-separated list of categories routed from inline comments
to the PR summary (fail-open: never drops a finding). Categories are
case-insensitive and drawn from: bug, security, performance,
maintainability, test, style, documentation, other. For example,
'style,documentation' routes those categories to the summary. Unknown
category tokens are ignored. Findings with an empty or unknown category
are never routed by this policy and keep their normal placement. Combine
with route_severity_below to route on either condition.
required: false
default: ''
checkpoint_range:
description: >-
Cross-push checkpoints. true = a run that reviewed everything it selected
records the head it covered in its sticky summary comment, and the next
run reviews only <checkpoint>..<new head> instead of
<merge-base>..<new head>. Fail-closed: if anything is in doubt — the
summary is missing or was not posted by this token, the marker is
unreadable, the base moved, the configuration changed, or git cannot prove
the checkpoint is an ancestor of the new head — the full range is reviewed
exactly as it is today. Requires sticky_summary; ignored without it.
required: false
default: 'false'
full_review:
description: >-
Force one full review even when checkpoint_range is enabled (reason
'manual_full_review'). Use it to re-review a PR from the merge-base
without turning checkpointing off; the run still records a new checkpoint.
required: false
default: 'false'
base_ref:
description: >-
Override the base ref. Provide this (and head_sha) when invoking from a
non-PR event such as issue_comment.
required: false
head_sha:
description: Override the head commit SHA (use with base_ref for comment triggers).
required: false
pr_number:
description: >-
Override the pull request number the run fetches and posts to. Defaults to
the number in the event payload (pull_request / pull_request_target /
issue_comment), then to github.event.workflow_run.pull_requests[0].number.
Provide it for a trigger that carries no pull request of its own, or when a
workflow_run payload lists more than one; the action fails before any
review work when no number resolves.
required: false
default: ''
node_version:
description: Node.js version for actions/setup-node.
required: false
default: '24'
outputs:
comments_total:
description: Total number of review comments generated by OCR.
value: ${{ steps.post.outputs.comments_total }}
comments_inline:
description: Number of inline comments successfully posted.
value: ${{ steps.post.outputs.comments_inline }}
comments_skipped:
description: Number of inline comments skipped by incremental mode (overlap with history).
value: ${{ steps.post.outputs.comments_skipped }}
comments_routed:
description: >-
Number of inline-eligible comments routed to the PR summary by the
route_severity_below / route_categories policy. Mutually exclusive with
comments_inline, comments_skipped, and comments_failed.
value: ${{ steps.post.outputs.comments_routed }}
comments_failed:
description: Number of inline comments that failed to post.
value: ${{ steps.post.outputs.comments_failed }}
comments_resolved:
description: >-
Number of outdated bot review threads resolved this run. Always 0 unless
resolve_outdated is 'true'.
value: ${{ steps.post.outputs.comments_resolved }}
comments_resolved_preview:
description: >-
Number of candidate threads resolve_outdated 'report' mode found. This is
the uncapped count, so it can exceed the 50 threads a single 'true' run
resolves. Always 0 in 'false' and 'true' modes.
value: ${{ steps.post.outputs.comments_resolved_preview }}
summary_comment_url:
description: URL of the posted/updated summary comment, if any.
value: ${{ steps.post.outputs.summary_comment_url }}
range_mode:
description: >-
'checkpoint' when this run reviewed only the range since the previous
checkpoint, 'full' when it reviewed from the merge-base. Empty when
checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_mode }}
range_summary:
description: >-
The reviewed range plus the reason it was chosen, e.g.
"full (base_changed)" or "checkpoint (ok): <from>..<to>". Empty when
checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_summary }}
range_reason:
description: >-
Why that mode was chosen: 'ok', 'same_head_noop', or one of the
fail-closed reasons ('base_changed', 'config_changed', 'not_ancestor', …).
Empty when checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_reason }}
range_from:
description: >-
The commit this run reviewed from, or empty when it reviewed from the
merge-base (every 'full' run, and every run with checkpoint_range off).
value: ${{ steps.range.outputs.range_from }}
range_to:
description: The head this run reviewed up to. Empty when checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_to }}
checkpoint_before:
description: >-
The head recorded by the checkpoint this run read, whether or not it was
used. Empty when no usable marker was read.
value: ${{ steps.range.outputs.checkpoint_before }}
ancestry:
description: >-
The ancestry verdict on the checkpoint that was read: 'ancestor',
'not_ancestor', 'unknown_object', 'error', or empty when it was not probed.
value: ${{ steps.range.outputs.ancestry }}
source_run:
description: The workflow run id that wrote the checkpoint this run read, if any.
value: ${{ steps.range.outputs.source_run }}
checkpoint_after:
description: >-
The head this run recorded as the new checkpoint, or empty when it did not
advance one (incomplete run, a blocking publication failure, or the summary did
not publish). Findings proven outside the PR diff and included in the
published summary do not block advancement.
value: ${{ steps.post.outputs.checkpoint_after }}
runs:
using: composite
steps:
- name: Check git and Node.js
id: check_deps
shell: bash
run: |
if command -v git >/dev/null 2>&1; then
echo "git_installed=true" >> "$GITHUB_OUTPUT"
echo "git is already installed: $(git --version)"
else
echo "git_installed=false" >> "$GITHUB_OUTPUT"
echo "git is not installed"
fi
if command -v node >/dev/null 2>&1; then
echo "node_installed=true" >> "$GITHUB_OUTPUT"
echo "node is already installed: $(node --version)"
else
echo "node_installed=false" >> "$GITHUB_OUTPUT"
echo "node is not installed"
fi
- name: Install git
if: steps.check_deps.outputs.git_installed != 'true'
shell: bash
run: |
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y git
elif command -v brew >/dev/null 2>&1; then
brew install git
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y git
elif command -v apk >/dev/null 2>&1; then
sudo apk add --no-cache git
else
echo "::error::Unable to install git: no supported package manager found"
exit 1
fi
git --version
- name: Setup Node.js
if: steps.check_deps.outputs.node_installed != 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node_version }}
- name: Resolve PR refs
shell: bash
env:
INPUT_BASE_REF: ${{ inputs.base_ref }}
INPUT_HEAD_SHA: ${{ inputs.head_sha }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
# workflow_run carries no pull request of its own, so the run that
# triggered it is reachable only through this array. GitHub fills it in
# only for head branches that live in this repository, and it can list
# more than one PR when several share a head; either case is what
# pr_number is for.
WORKFLOW_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
run: |
BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}"
PR_NUMBER="${INPUT_PR_NUMBER:-${EVENT_PR_NUMBER:-$WORKFLOW_RUN_PR_NUMBER}}"
# Fail here rather than after the review: everything downstream posts to
# this number, and an empty one used to reach GitHub as
# /repos/OWNER/REPO/issues//comments — a 404 that discarded a completed
# review's findings.
if [ -z "$PR_NUMBER" ]; then
echo "::error::No pull request number: the '${GITHUB_EVENT_NAME:-unknown}' event payload carries none. Pass the pr_number input; a workflow_run payload names the PR under workflow_run.pull_requests[0].number."
exit 1
fi
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::Resolved pull request number '${PR_NUMBER}' is not a positive integer; pr_number must be the PR's number alone."
exit 1
fi
echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV"
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
echo "PR base ref: $BASE_REF"
echo "PR head sha: $HEAD_SHA"
echo "PR number: $PR_NUMBER"
- name: Checkout base
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Checkout the trusted base, not the PR head. OCR reviews the
# base-to-head diff from git objects; the head commit's blobs are
# fetched separately so they are resolvable without materializing
# untrusted PR files into the working tree.
fetch-depth: 0
- name: Fetch PR head (fork-safe)
if: env.HEAD_SHA != ''
shell: bash
env:
PR_NUM: ${{ env.PR_NUMBER }}
run: |
if [ -n "$PR_NUM" ]; then
git fetch origin "pull/${PR_NUM}/head"
fi
- name: Compute merge-base
shell: bash
run: |
git fetch origin "${BASE_REF}" 2>/dev/null || true
MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}")
echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV"
echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})"
- name: Validate inputs
env:
REVIEW_TASK_TIMEOUT: ${{ inputs.review_task_timeout }}
EFFORT_INPUT: ${{ inputs.effort }}
MAX_TOKENS_BUDGET_INPUT: ${{ inputs.max_tokens_budget }}
LLM_REASONING_EFFORT_INPUT: ${{ inputs.llm_reasoning_effort }}
STREAM_PROGRESS_INPUT: ${{ inputs.stream_progress }}
RESOLVE_OUTDATED_INPUT: ${{ inputs.resolve_outdated }}
shell: bash
run: |
if [[ ! "$REVIEW_TASK_TIMEOUT" =~ ^[0-9]+$ ]]; then
echo "::error::review_task_timeout must be a base-10 integer from 1 through 120 minutes"
exit 1
fi
NORMALIZED_REVIEW_TASK_TIMEOUT="$REVIEW_TASK_TIMEOUT"
while [[ "$NORMALIZED_REVIEW_TASK_TIMEOUT" == 0* && "$NORMALIZED_REVIEW_TASK_TIMEOUT" != "0" ]]; do
NORMALIZED_REVIEW_TASK_TIMEOUT="${NORMALIZED_REVIEW_TASK_TIMEOUT#0}"
done
if [[ "$NORMALIZED_REVIEW_TASK_TIMEOUT" == "0" || ${#NORMALIZED_REVIEW_TASK_TIMEOUT} -gt 3 ]]; then
echo "::error::review_task_timeout must be between 1 and 120 minutes"
exit 1
fi
REVIEW_TASK_TIMEOUT_NUMBER=$((10#$NORMALIZED_REVIEW_TASK_TIMEOUT))
if (( REVIEW_TASK_TIMEOUT_NUMBER < 1 || REVIEW_TASK_TIMEOUT_NUMBER > 120 )); then
echo "::error::review_task_timeout must be between 1 and 120 minutes"
exit 1
fi
echo "REVIEW_TASK_TIMEOUT=$REVIEW_TASK_TIMEOUT_NUMBER" >> "$GITHUB_ENV"
# Empty keeps the CLI default; otherwise one of the named presets.
# Normalized to lowercase so the Run step appends a value `ocr review
# --effort` accepts verbatim.
NORMALIZED_EFFORT="$(printf '%s' "$EFFORT_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_EFFORT" in
""|low|medium|high) ;;
*)
echo "::error::effort must be one of: low, medium, high (got '$EFFORT_INPUT')"
exit 1
;;
esac
echo "EFFORT=$NORMALIZED_EFFORT" >> "$GITHUB_ENV"
# Empty or 0 means unlimited; both normalize to empty so the Run step
# simply omits the flag.
NORMALIZED_MAX_TOKENS_BUDGET="$MAX_TOKENS_BUDGET_INPUT"
if [[ -n "$NORMALIZED_MAX_TOKENS_BUDGET" ]]; then
if [[ ! "$NORMALIZED_MAX_TOKENS_BUDGET" =~ ^[0-9]+$ ]]; then
echo "::error::max_tokens_budget must be a base-10 integer >= 0 (empty or 0 = unlimited)"
exit 1
fi
while [[ "$NORMALIZED_MAX_TOKENS_BUDGET" == 0* && "$NORMALIZED_MAX_TOKENS_BUDGET" != "0" ]]; do
NORMALIZED_MAX_TOKENS_BUDGET="${NORMALIZED_MAX_TOKENS_BUDGET#0}"
done
if [[ "$NORMALIZED_MAX_TOKENS_BUDGET" == "0" ]]; then
NORMALIZED_MAX_TOKENS_BUDGET=""
fi
fi
echo "MAX_TOKENS_BUDGET=$NORMALIZED_MAX_TOKENS_BUDGET" >> "$GITHUB_ENV"
# Empty leaves the request body untouched; otherwise the union of the
# OpenAI and GLM vocabularies, normalized to lowercase for the
# extra_body injection in the Configure OCR step (which uses node,
# guaranteed on PATH by the Actions runtime).
NORMALIZED_LLM_REASONING_EFFORT="$(printf '%s' "$LLM_REASONING_EFFORT_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_LLM_REASONING_EFFORT" in
""|minimal|low|medium|high|max) ;;
*)
echo "::error::llm_reasoning_effort must be one of: minimal, low, medium, high, max (got '$LLM_REASONING_EFFORT_INPUT')"
exit 1
;;
esac
echo "LLM_REASONING_EFFORT=$NORMALIZED_LLM_REASONING_EFFORT" >> "$GITHUB_ENV"
# Display-only toggle: 'true' streams human-audience progress lines to
# the workflow log, 'false' keeps the silent agent-audience run. It
# changes nothing the review would say, so it stays out of the
# checkpoint config fingerprint.
NORMALIZED_STREAM_PROGRESS="$(printf '%s' "$STREAM_PROGRESS_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_STREAM_PROGRESS" in
"") NORMALIZED_STREAM_PROGRESS=false ;;
true|false) ;;
*)
echo "::error::stream_progress must be one of: true, false (got '$STREAM_PROGRESS_INPUT')"
exit 1
;;
esac
echo "STREAM_PROGRESS=$NORMALIZED_STREAM_PROGRESS" >> "$GITHUB_ENV"
# Thread cleanup mode: 'false' touches nothing and makes no API calls,
# 'report' logs what it would resolve, 'true' resolves. Empty maps to
# 'false' so a caller that passes an unset variable stays opt-in.
NORMALIZED_RESOLVE_OUTDATED="$(printf '%s' "$RESOLVE_OUTDATED_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_RESOLVE_OUTDATED" in
"") NORMALIZED_RESOLVE_OUTDATED=false ;;
false|report|true) ;;
*)
echo "::error::resolve_outdated must be one of: false, report, true (got '$RESOLVE_OUTDATED_INPUT')"
exit 1
;;
esac
echo "RESOLVE_OUTDATED=$NORMALIZED_RESOLVE_OUTDATED" >> "$GITHUB_ENV"
- name: Install OpenCodeReview
shell: bash
env:
OCR_VERSION: ${{ inputs.ocr_version }}
run: |
npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}"
echo "OpenCodeReview installed:"
OCR_VERSION_OUTPUT="$(ocr version 2>/dev/null)" || {
echo "::error::Unable to read the installed OpenCodeReview version"
exit 1
}
printf '%s\n' "$OCR_VERSION_OUTPUT"
OCR_VERSION_LINE="${OCR_VERSION_OUTPUT%%$'\n'*}"
if [[ ! "$OCR_VERSION_LINE" =~ ^open-code-review[[:space:]]+v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?([[:space:]]|$) ]]; then
echo "::error::Unable to parse the installed OpenCodeReview version; a stable release v1.9.6 or newer is required (prerelease versions are not supported)"
exit 1
fi
OCR_VERSION_MAJOR=$((10#${BASH_REMATCH[1]}))
OCR_VERSION_MINOR=$((10#${BASH_REMATCH[2]}))
OCR_VERSION_PATCH=$((10#${BASH_REMATCH[3]}))
if (( OCR_VERSION_MAJOR < 1 )) ||
(( OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR < 9 )) ||
(( OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR == 9 && OCR_VERSION_PATCH < 6 )); then
echo "::error::Installed OpenCodeReview ${OCR_VERSION_LINE} is unsupported; v1.9.6 or newer is required"
exit 1
fi
# --effort was introduced in v1.10.0; reject an explicit effort input
# on older releases instead of letting the CLI fail on an unknown flag.
# max_tokens_budget predates the v1.9.6 floor and llm_reasoning_effort
# rides extra_body, so neither needs a gate.
if [ -n "${EFFORT:-}" ] &&
(( OCR_VERSION_MAJOR < 1 || (OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR < 10) )); then
echo "::error::The effort input requires OpenCodeReview v1.10.0 or newer"
exit 1
fi
# Human-audience progress only moved to stderr in v1.9.8 (66d71b2); on
# older releases stream_progress drops --audience agent and progress
# lines interleave into the stdout JSON, corrupting the result file.
if [ "${STREAM_PROGRESS:-false}" = "true" ] &&
(( OCR_VERSION_MAJOR < 1 || (OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR < 9) ||
(OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR == 9 && OCR_VERSION_PATCH < 8) )); then
echo "::error::The stream_progress input requires OpenCodeReview v1.9.8 or newer"
exit 1
fi
# Resolved version (not the spec, which is usually "latest"). It feeds
# the checkpoint fingerprint so an OCR upgrade invalidates checkpoints
# taken by the previous version. The gate above already guarantees a
# parseable line here; the resolve step still treats an empty version
# as "fingerprint nothing" so a future gap can't silently narrow.
VERSION_ACTUAL="${OCR_VERSION_LINE//$'\r'/}"
echo "OCR_VERSION_ACTUAL=${VERSION_ACTUAL}" >> "$GITHUB_ENV"
- name: Configure OCR
env:
OCR_LLM_URL: ${{ inputs.llm_url }}
OCR_LLM_MODEL: ${{ inputs.llm_model }}
OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
OCR_LLM_PROTOCOL_INPUT: ${{ inputs.llm_protocol }}
OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}
OCR_LANGUAGE: ${{ inputs.language }}
shell: bash
run: |
# An explicit protocol wins over llm_use_anthropic: the input first,
# then OCR_LLM_PROTOCOL inherited from the job or step environment.
# Read it before the derivation below reuses that variable name.
EXPLICIT_PROTOCOL="$(printf '%s' "${OCR_LLM_PROTOCOL_INPUT:-${OCR_LLM_PROTOCOL:-}}" | tr '[:upper:]' '[:lower:]')"
NORMALIZED_USE_ANTHROPIC="$(printf '%s' "$OCR_USE_ANTHROPIC" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_USE_ANTHROPIC" in
""|true|1|yes)
OCR_USE_ANTHROPIC="true"
OCR_LLM_PROTOCOL="anthropic"
;;
*)
OCR_USE_ANTHROPIC="false"
OCR_LLM_PROTOCOL="openai"
;;
esac
# use_anthropic is mirrored from the explicit protocol the same way
# `ocr config set llm.protocol` mirrors it, so the two never disagree.
case "$EXPLICIT_PROTOCOL" in
"")
;;
anthropic)
OCR_USE_ANTHROPIC="true"
OCR_LLM_PROTOCOL="anthropic"
;;
openai|openai-responses)
OCR_USE_ANTHROPIC="false"
OCR_LLM_PROTOCOL="$EXPLICIT_PROTOCOL"
;;
*)
echo "::error::llm_protocol must be anthropic, openai or openai-responses (got '${EXPLICIT_PROTOCOL}')"
exit 1
;;
esac
# Exported for the checkpoint fingerprint: the normalized explicit
# protocol, empty when neither the input nor the inherited variable
# was set, so checkpoints taken before the input existed stay valid.
echo "OCR_LLM_PROTOCOL_EXPLICIT=${EXPLICIT_PROTOCOL}" >> "$GITHUB_ENV"
# reasoning_effort is OpenAI-compatible vocabulary; the Anthropic API
# rejects unknown body fields, so fail fast instead of breaking every
# request. Anthropic thinking control goes through an explicit
# llm_extra_body key instead.
if [ -n "${LLM_REASONING_EFFORT:-}" ] && [ "$OCR_LLM_PROTOCOL" = "anthropic" ]; then
echo "::error::llm_reasoning_effort is supported only with OpenAI-compatible protocols"
exit 1
fi
ocr config unset provider
ocr config set llm.auth_token ""
ocr config set llm.extra_headers ""
ocr config set llm.retry_codes ""
ocr config set llm.url "$OCR_LLM_URL"
ocr config set llm.model "$OCR_LLM_MODEL"
ocr config set llm.use_anthropic "$OCR_USE_ANTHROPIC"
ocr config set llm.protocol "$OCR_LLM_PROTOCOL"
ocr config set llm.auth_header "$OCR_LLM_AUTH_HEADER"
ocr config set llm.auth_token_cmd 'printf "%s" "$OCR_LLM_TOKEN"'
# reasoning_effort rides the existing extra_body merge, so any
# published CLI version supports it. An explicit reasoning_effort key
# in llm_extra_body wins over the input — the hand-written body is the
# more specific intent. The value was validated and normalized by the
# Validate inputs step. node (not jq) does the merge: the Actions
# runtime guarantees node on PATH even inside container jobs, while jq
# is only preinstalled on GitHub-hosted images. An explicitly empty
# extra_body parses as an empty object so the merge still lands.
EFFECTIVE_EXTRA_BODY="$OCR_EXTRA_BODY"
if [ -n "${LLM_REASONING_EFFORT:-}" ]; then
EFFECTIVE_EXTRA_BODY="$(printf '%s' "$EFFECTIVE_EXTRA_BODY" | node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk)).on("end", () => {
let body;
try {
body = raw ? JSON.parse(raw) : {};
} catch (error) {
// stderr escapes the command substitution, so the workflow
// command reaches the log; the non-zero exit fails the step.
console.error(`::error::llm_extra_body is not valid JSON: ${error.message}`);
process.exit(1);
}
if (body === null || typeof body !== "object" || Array.isArray(body)) {
console.error("::error::llm_extra_body must be a JSON object");
process.exit(1);
}
if (body.reasoning_effort === undefined) body.reasoning_effort = process.env.LLM_REASONING_EFFORT;
process.stdout.write(JSON.stringify(body));
});')"
fi
ocr config set llm.extra_body "$EFFECTIVE_EXTRA_BODY"
ocr config set language "$OCR_LANGUAGE"
- name: Resolve review range
if: inputs.checkpoint_range == 'true'
id: range
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
OCR_FULL_REVIEW: ${{ inputs.full_review }}
OCR_STICKY_SUMMARY: ${{ inputs.sticky_summary }}
# "reopened" / "ready_for_review" ask for a fresh look at the whole PR.
OCR_EVENT_ACTION: ${{ github.event.action }}
# Written to $GITHUB_ENV by the "Resolve PR refs" and "Compute
# merge-base" steps above. Declared here rather than read straight off
# the ambient job env so this step's inputs are all in one place and the
# dependency on those two steps is visible in the file. Same values
# either way — a skipped upstream step yields "", which the resolver
# already fails closed on: an empty baseRef/mergeBase cannot equal the
# non-empty ones validateCheckpointPayload requires, so it reports
# base_changed and reviews the full range.
OCR_HEAD_SHA: ${{ env.HEAD_SHA }}
OCR_BASE_REF: ${{ env.BASE_REF }}
OCR_MERGE_BASE: ${{ env.MERGE_BASE }}
# Same origin, but this one cannot arrive empty: "Resolve PR refs" fails
# the job when no PR number resolves, so a checkpoint is always read for
# a known PR rather than for issue //comments.
OCR_PR_NUMBER: ${{ env.PR_NUMBER }}
# Everything that changes what a review would say. Any difference
# invalidates the checkpoint, because findings from the previous run are
# no longer comparable to what this configuration would produce.
#
# One variable per axis, joined in the script rather than here. A single
# pre-joined string would make the digest depend on two things it has no
# business depending on: how this YAML happens to be wrapped (a folded
# scalar re-wrapped by a reformat is a different string), and on no input
# value ever containing the separator — and a value that does contain it
# shifts the field boundaries, so two different configurations can hash
# alike and a checkpoint survives a change that should have killed it.
OCR_FP_LLM_URL: ${{ inputs.llm_url }}
OCR_FP_LLM_MODEL: ${{ inputs.llm_model }}
OCR_FP_LLM_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
OCR_FP_LLM_PROTOCOL: ${{ env.OCR_LLM_PROTOCOL_EXPLICIT }}
OCR_FP_LANGUAGE: ${{ inputs.language }}
OCR_FP_LLM_EXTRA_BODY: ${{ inputs.llm_extra_body }}
# Normalized by Validate inputs, so spellings that mean the same thing
# (HIGH vs high, '0' vs '' vs '00') hash identically and keep the
# checkpoint.
OCR_FP_LLM_REASONING_EFFORT: ${{ env.LLM_REASONING_EFFORT }}
OCR_FP_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
# A timeout change shifts which runs finish and which are cut short, so
# it shifts the partial/complete distribution the checkpoint gates on.
OCR_FP_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
# Effort shifts how many rounds a review runs and the budget caps how
# much it may spend; both change what a review would say, so both
# invalidate the checkpoint. Both read the values Validate inputs
# normalized into the environment, not the raw inputs.
OCR_FP_EFFORT: ${{ env.EFFORT }}
OCR_FP_MAX_TOKENS_BUDGET: ${{ env.MAX_TOKENS_BUDGET }}
# Extra headers can point the same `llm_model` string at a different
# backend model or a different provider entirely, so they change what a
# review would say and must invalidate the checkpoint. Their VALUES can
# be credentials, so the script hashes this axis before it joins the
# others — the same treatment the rule file contents get. Only the
# digest reaches the fingerprint, and the fingerprint is all the stored
# checkpoint ever carries.
OCR_FP_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
OCR_FP_RULE: ${{ inputs.rule }}
OCR_FP_ROUTE_SEVERITY_BELOW: ${{ inputs.route_severity_below }}
OCR_FP_ROUTE_CATEGORIES: ${{ inputs.route_categories }}
OCR_FP_BACKGROUND: ${{ inputs.background }}
OCR_RULE_PATH: ${{ inputs.rule }}
# `github.token` is always the "github-actions" app, so when the caller
# did not override the token we know exactly which app wrote our summary
# and can pin the author check to it instead of trusting any bot. A
# custom token may belong to any app and an installation token cannot
# ask GitHub which app it is (GET /app needs a JWT), so that case keeps
# the wider "any writer GitHub attributes to a bot" check.
OCR_CHECKPOINT_APP_SLUG: ${{ inputs.github_token == github.token && 'github-actions' || '' }}
with:
github-token: ${{ inputs.github_token }}
script: |
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
// Everything this step publishes, in one place so the failure path
// below can publish the same shape. Empty range_from means "review the
// full range": the review step expands ${RANGE_FROM:-$MERGE_BASE}, so
// unset and empty behave alike. These are STEP outputs, not job env:
// a second use of this action in the same job must not inherit a
// narrowed range from the first one.
const emit = (range, fingerprint, carry) => {
const summary = range.mode === 'checkpoint'
? `checkpoint (${range.reason}): ${range.from}..${range.to}`
: `full (${range.reason})`;
core.setOutput('range_from', range.mode === 'checkpoint' ? range.from : '');
core.setOutput('range_to', range.to || '');
core.setOutput('range_mode', range.mode);
core.setOutput('range_reason', range.reason);
core.setOutput('range_summary', summary);
core.setOutput('checkpoint_before', range.checkpointBefore || '');
core.setOutput('ancestry', range.ancestry || '');
core.setOutput('source_run', range.sourceRun || '');
core.setOutput('config_fingerprint', fingerprint);
core.setOutput('checkpoint_carry', carry);
core.info(`[checkpoint] reviewing ${summary}`);
};
// This step only chooses where the review starts, and every failure it
// can hit has the same safe answer: review the whole merge-base range.
// So nothing in here may fail the job — a missing helper or an API
// outage must not block a review the action can still perform.
try {
// Same helper lookup as the posting step below.
const REL = 'scripts/github-actions/post-review-comments.js';
const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean);
const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p));
if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`);
const { resolveCheckpointRange, readCheckpointComment } = require(helper);
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
let ruleUnverified = false;
// `rule` names a JSON file that OCR reads off the workspace at review
// time (rules.NewResolver only touches disk when the path is non-empty;
// the default rule set is embedded in the binary and so already moves
// with OCR_VERSION_ACTUAL). Fingerprinting the *path* alone would let an
// edit to that file narrow the next range under rules the earlier
// commits were never reviewed against, so hash the contents too.
let ruleDigest = 'none';
const rulePath = process.env.OCR_RULE_PATH || '';
if (rulePath) {
try {
ruleDigest = sha256(fs.readFileSync(path.resolve(process.env.GITHUB_WORKSPACE || '.', rulePath)));
} catch (e) {
// Cannot prove the rules are unchanged -> do not narrow. OCR itself
// would normally have failed on an unreadable rule file before this
// step runs, so this is a belt-and-braces path.
ruleUnverified = true;
core.warning(`checkpoint: cannot read rule file ${rulePath} (${e.message}); forcing a full review.`);
}
}
// .opencodereview/rule.json is loaded from the repo whether or not
// `rule` is set (rules.NewResolver reads <repoDir>/.opencodereview/rule.json
// at high priority), so a commit that edits it changes what a review
// says and must invalidate the checkpoint the same way.
let localRuleDigest = 'none';
const localRulePath = path.resolve(process.env.GITHUB_WORKSPACE || '.', '.opencodereview/rule.json');
if (fs.existsSync(localRulePath)) {
try {
localRuleDigest = sha256(fs.readFileSync(localRulePath));
} catch (e) {
ruleUnverified = true;
core.warning(`checkpoint: cannot read .opencodereview/rule.json (${e.message}); forcing a full review.`);
}
}
// Header values can be credentials, so this axis goes in as a
// digest. Same shape as ruleDigest: 'none' when unset, so "no extra
// headers" and "extra headers that hash to nothing" stay distinct.
const extraHeaders = process.env.OCR_FP_LLM_EXTRA_HEADERS || '';
const extraHeadersDigest = extraHeaders ? sha256(extraHeaders) : 'none';
// `ocr version` printed nothing, so this run cannot say which OCR
// version it is about to use and no stored fingerprint can be
// trusted to mean "same version". An empty fingerprint is the
// honest answer: it matches no stored one (-> config_changed, full
// review) and the posting step refuses to record a checkpoint
// without it. Don't narrow, don't advance.
const versionActual = process.env.OCR_VERSION_ACTUAL || '';
if (!versionActual) {
core.warning('checkpoint: `ocr version` printed no version; reviewing the full range and not recording a checkpoint.');
}
// JSON, not a joined string: it is length-delimited, so no input
// value can shift a field boundary and make two different
// configurations hash alike.
const fingerprint = !versionActual ? '' : crypto.createHash('sha256')
.update(JSON.stringify([
process.env.OCR_FP_LLM_URL,
process.env.OCR_FP_LLM_MODEL,
process.env.OCR_FP_LLM_USE_ANTHROPIC,
// The explicit protocol as Configure OCR resolved it — the input
// first, then OCR_LLM_PROTOCOL inherited from the job — so a
// protocol switched through the environment alone still fails
// closed to a full review. Appended only when set, so
// checkpoints taken before the input existed stay valid.
...(process.env.OCR_FP_LLM_PROTOCOL ? [process.env.OCR_FP_LLM_PROTOCOL] : []),
process.env.OCR_FP_LANGUAGE,
process.env.OCR_FP_LLM_EXTRA_BODY,
process.env.OCR_FP_LLM_REASONING_EFFORT,
process.env.OCR_FP_LLM_AUTH_HEADER,
process.env.OCR_FP_LLM_TIMEOUT,
process.env.OCR_FP_EFFORT,
process.env.OCR_FP_MAX_TOKENS_BUDGET,
extraHeadersDigest,
process.env.OCR_FP_RULE,
process.env.OCR_FP_ROUTE_SEVERITY_BELOW,
process.env.OCR_FP_ROUTE_CATEGORIES,
process.env.OCR_FP_BACKGROUND,
versionActual,
ruleDigest,
localRuleDigest,
].map((v) => v || '')))
.digest('hex')
.slice(0, 16);
// git's own ancestry verdict: 0 = ancestor, 1 = not, 128 = the object
// is not in this clone (shallow fetch, force-push, head_sha override).
// Anything else (git missing, signal) is a resolver error. Never
// treated as "ancestor" except on a literal 0.
const isAncestor = (a, b) =>
spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: process.env.GITHUB_WORKSPACE }).status;
const common = {
github,
owner: context.repo.owner,
repo: context.repo.repo,
prNumber: Number(process.env.OCR_PR_NUMBER),
// Non-empty only when the token is the default one, whose app
// identity is known; empty keeps the wider bot check.
appSlug: process.env.OCR_CHECKPOINT_APP_SLUG || '',
log: (m) => core.info(m),
};
// One read serves both purposes: the range decision, and the verbatim
// marker the posting step re-emits on a run that does not advance the
// checkpoint (the summary body is rewritten wholesale, which would
// otherwise erase it). Passing it into the resolver as `read` is what
// keeps this to a single listComments pagination per run.
const existing = await readCheckpointComment(common);
const range = await resolveCheckpointRange(Object.assign({}, common, {
read: existing,
enabled: true,
sticky: process.env.OCR_STICKY_SUMMARY === 'true',
fullReview: process.env.OCR_FULL_REVIEW === 'true',
eventAction: process.env.OCR_EVENT_ACTION || '',
headSha: process.env.OCR_HEAD_SHA || '',
baseRef: process.env.OCR_BASE_REF || '',
mergeBase: process.env.OCR_MERGE_BASE || '',
fingerprint,
isAncestor,
}));
// Last gate, applied after the ordered ones in the resolver: the rules
// this run will apply could not be read, so no stored fingerprint can
// be trusted to mean "same rules". Widening is always safe; narrowing
// is not.
if (ruleUnverified && range.mode === 'checkpoint') {
range.mode = 'full';
range.reason = 'rule_unreadable';
}
emit(range, fingerprint, existing.raw || '');
} catch (e) {
core.warning(`checkpoint: could not resolve a range (${e.message}); reviewing the full range.`);
emit({ mode: 'full', reason: 'resolver_error', from: '', to: process.env.OCR_HEAD_SHA || '' }, '', '');
}
- name: Run OpenCodeReview
env:
OCR_LLM_URL: ${{ inputs.llm_url }}
OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }}
OCR_LLM_MODEL: ${{ inputs.llm_model }}
OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }}
OCR_BACKGROUND: ${{ inputs.background }}
OCR_RULE: ${{ inputs.rule }}
# Step output, not job env: empty when the resolve step was skipped or
# chose the full range, and never inherited from an earlier use of this
# action in the same job.
RANGE_FROM: ${{ steps.range.outputs.range_from }}
shell: bash
run: |
export OCR_LLM_TIMEOUT="${OCR_LLM_TIMEOUT:-300}"
if [ -z "${REVIEW_TASK_TIMEOUT:-}" ]; then
echo "::error::Validated review_task_timeout is missing; the Validate inputs step must complete first"
exit 1
fi
ARGS=(--from "${RANGE_FROM:-$MERGE_BASE}" --to "${HEAD_SHA}")
# stream_progress 'false' (default): agent audience, stderr captured to
# the log file without a live tee. Opt-in 'true': no --audience agent,
# so human-audience routing sends [ocr] progress lines to stderr; a
# FIFO feeds a background tee so progress streams live into the
# workflow log while still landing in the file for artifacts and the
# posting step. The tee is a real background job rather than process
# substitution so `wait` below guarantees the file is fully flushed
# before anything reads it. Neither mode disturbs $?.
[ "${STREAM_PROGRESS:-false}" = "true" ] || ARGS+=(--audience agent)
ARGS+=(--format json --timeout "$REVIEW_TASK_TIMEOUT")
[ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY")
[ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")
[ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE")
[ -n "${EFFORT:-}" ] && ARGS+=(--effort "$EFFORT")
[ -n "${MAX_TOKENS_BUDGET:-}" ] && ARGS+=(--max-tokens-budget "$MAX_TOKENS_BUDGET")
set +e
if [ "${STREAM_PROGRESS:-false}" = "true" ]; then
OCR_STDERR_FIFO="$(mktemp -u)"
mkfifo "$OCR_STDERR_FIFO" || exit 1
tee /tmp/ocr-stderr.log < "$OCR_STDERR_FIFO" >&2 &
TEE_PID=$!
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2> "$OCR_STDERR_FIFO"
OCR_EXIT_CODE=$?
wait "$TEE_PID"
rm -f "$OCR_STDERR_FIFO"
else
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log