-
Notifications
You must be signed in to change notification settings - Fork 0
3026 lines (2629 loc) · 137 KB
/
codex-code-review.yml
File metadata and controls
3026 lines (2629 loc) · 137 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: Codex Code Review
# Dynamic run name shows PR number for easy identification in the Actions UI
# Format: "Codex Code Review (PR #123)" or "Codex Code Review (branch-name)"
run-name: >-
${{ github.event_name == 'workflow_dispatch' && format('Codex Code Review (PR #{0})', inputs.pr_number) ||
github.event_name == 'issue_comment' && format('Codex Code Review (PR #{0})', github.event.issue.number) ||
github.event_name == 'pull_request' && format('Codex Code Review (PR #{0})', github.event.pull_request.number) ||
format('Codex Code Review ({0})', github.event.workflow_run.head_branch) }}
# SECURITY NOTE: This workflow grants the Codex agent access to secrets and write permissions.
# Authorization checks are implemented to prevent prompt injection and code execution from untrusted users.
# - Only PRs from repository collaborators, members, and owners trigger Codex reviews
# - The devcontainer is NEVER rebuilt from PR branches to prevent malicious Dockerfile injection
# - External PRs (from forks) are blocked from triggering Codex
on:
workflow_run:
workflows: ["PR Tests"]
types: [completed]
# Allow re-triggering reviews via /review comment on a PR
issue_comment:
types: [created]
# Clear review labels when PR is closed
pull_request:
types: [closed]
# Allow manual triggering after workflow_dispatch-triggered PR Tests completes
# (workflow_run events don't fire for workflow_dispatch triggers)
workflow_dispatch:
inputs:
head_ref:
description: 'PR branch name'
required: true
type: string
head_repo:
description: 'PR repository (owner/repo format)'
required: true
type: string
pr_number:
description: 'PR number'
required: true
type: string
tests_passed:
description: 'Whether tests passed (true/false)'
required: true
type: string
run_id:
description: 'PR Tests workflow run ID'
required: true
type: string
reviewed_sha:
description: 'Immutable PR head SHA reviewed by this workflow run'
required: false
type: string
review_cycle:
description: 'Review cycle hint (labels are authoritative)'
required: false
type: string
# Prevent duplicate reviews when multiple PR Tests complete around the same time
# Uses branch name for concurrency group
# cancel-in-progress: false ensures we complete the first review rather than restarting
concurrency:
group: codex-review-${{ github.event_name == 'workflow_dispatch' && inputs.head_ref || github.event_name == 'issue_comment' && format('pr-{0}', github.event.issue.number) || github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.event.workflow_run.head_branch }}
cancel-in-progress: false
env:
# Note: must be lowercase for Docker compatibility
DEVCONTAINER_IMAGE: ghcr.io/nickborgersprobably/hide-my-list-devcontainer
PR_WORKSPACE_PATH: pr-head
jobs:
# Extract PR and Issue context from workflow_run or workflow_dispatch event
get-context:
# ATOMIC PIPELINE SWAP GATE.
#
# When the repo variable REVIEW_PIPELINE_V2 is set to 'true', this
# entire legacy monolith short-circuits. Every other job in this
# file `needs: get-context` (directly or transitively), so skipping
# get-context cascades dormancy through the whole graph. The v2
# pipeline (.github/workflows/review-entry.yml) takes over via the
# mirror gate `vars.REVIEW_PIPELINE_V2 == 'true'`.
#
# Flipping the variable back to anything else (or unsetting it)
# restores this monolith and dormancy moves to v2. Zero data
# migration in either direction.
#
# Gate target: every job that needs: get-context is downstream of
# this skip. The remaining v1-only jobs (create-residual-gap-issue,
# all-reviews-passed) also `needs: get-context`, so they skip too.
#
# See .claude/plans/reflective-coalescing-peach.md for the full
# rearchitecture plan. See PR #343 for the v2 dormant landing.
if: vars.REVIEW_PIPELINE_V2 != 'true'
runs-on: ubuntu-latest
# Note: We don't filter by event type here. The concurrency group prevents
# duplicate runs for the same branch, and downstream jobs skip if no PR is found.
# This allows reviews to run even when push happens before PR creation.
permissions:
contents: read
issues: write
pull-requests: write
statuses: write
outputs:
pr_number: ${{ steps.get-pr.outputs.pr_number }}
pr_title: ${{ steps.get-pr.outputs.pr_title }}
pr_body_b64: ${{ steps.get-pr.outputs.pr_body_b64 }}
head_ref: ${{ steps.get-pr.outputs.head_ref }}
head_repo: ${{ steps.get-pr.outputs.head_repo }}
# Draft PR status - used to skip auto-fix pipeline for TDD workflows
is_draft: ${{ steps.get-pr.outputs.is_draft }}
# Handle workflow_run, workflow_dispatch, issue_comment, and pull_request events
# For issue_comment (/review), look up latest PR Tests result
# For pull_request (close), tests_passed is irrelevant (downstream jobs skip)
tests_passed: ${{ github.event_name == 'workflow_dispatch' && inputs.tests_passed == 'true' || github.event_name == 'issue_comment' && steps.lookup-tests.outputs.tests_passed || github.event_name == 'pull_request' && 'false' || github.event.workflow_run.conclusion == 'success' }}
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event_name == 'issue_comment' && steps.lookup-tests.outputs.run_id || github.event_name == 'pull_request' && github.run_id || github.event.workflow_run.id }}
# Workflow run ID for the PR Tests execution that validated this PR head.
# reviewer-setup uses that run to verify the current PR head SHA before
# checking out the immutable commit for each review job.
pr_tests_run_id: ${{ github.event_name == 'issue_comment' && steps.lookup-tests.outputs.run_id || github.event_name == 'pull_request' && '' || github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
# Immutable SHA reviewed by this workflow run and used for status reporting.
reviewed_sha: ${{ steps.resolve-reviewed-sha.outputs.reviewed_sha }}
# SHA that triggered this workflow - used to detect if author pushed newer commits
triggering_sha: ${{ steps.resolve-reviewed-sha.outputs.reviewed_sha }}
issue_number: ${{ steps.get-issue.outputs.issue_number }}
issue_title: ${{ steps.get-issue.outputs.issue_title }}
issue_body_b64: ${{ steps.get-issue.outputs.issue_body_b64 }}
fix_attempts: ${{ steps.count-attempts.outputs.count }}
reviews_already_passed: ${{ steps.check-existing-reviews.outputs.already_passed }}
review_skip_reason: ${{ steps.check-existing-reviews.outputs.skip_reason }}
# SECURITY: Authorization status - blocks external/untrusted PR authors
author_authorized: ${{ steps.check-author-auth.outputs.authorized }}
# File classification - determines which reviews to run
config_only: ${{ steps.classify-files.outputs.config_only }}
has_scripts: ${{ steps.classify-files.outputs.has_scripts }}
docs_only: ${{ steps.classify-files.outputs.docs_only }}
review_cycle: ${{ steps.resolve-review-cycle.outputs.review_cycle }}
review_cycle_capped: ${{ steps.resolve-review-cycle.outputs.capped }}
steps:
# Gate for /review comment triggers — skip non-matching comments early
- name: Check /review comment trigger
id: comment-gate
if: github.event_name == 'issue_comment'
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
# Must be a PR comment (issues don't have pull_request field)
if [ -z "${{ github.event.issue.pull_request.url }}" ]; then
echo "Not a PR comment, skipping"
echo "valid=false" >> $GITHUB_OUTPUT
exit 0
fi
# Must contain /review command
if ! echo "$COMMENT_BODY" | grep -qE '^\s*/review\s*$'; then
echo "Comment does not contain /review command"
echo "valid=false" >> $GITHUB_OUTPUT
exit 0
fi
# Authorization check
AUTHOR_ASSOC="${{ github.event.comment.author_association }}"
case "$AUTHOR_ASSOC" in
OWNER|MEMBER|COLLABORATOR)
echo "Authorized: ${{ github.event.comment.user.login }} ($AUTHOR_ASSOC)"
echo "valid=true" >> $GITHUB_OUTPUT
;;
*)
echo "Not authorized: ${{ github.event.comment.user.login }} ($AUTHOR_ASSOC)"
echo "valid=false" >> $GITHUB_OUTPUT
;;
esac
- name: Acknowledge /review comment
if: github.event_name == 'issue_comment' && steps.comment-gate.outputs.valid == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
-f content='eyes' || true
# Look up the latest PR Tests result for /review triggers
- name: Look up PR Tests status
id: lookup-tests
if: github.event_name == 'issue_comment' && steps.comment-gate.outputs.valid == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ github.event.issue.number }}"
# Get PR head SHA and branch
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
HEAD_SHA=$(echo "$PR_DATA" | jq -r '.head.sha')
HEAD_REF=$(echo "$PR_DATA" | jq -r '.head.ref')
echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT
# Find most recent completed PR Tests run for this branch
RUN_JSON=$(gh api "repos/${{ github.repository }}/actions/workflows/pr-tests.yml/runs?branch=$HEAD_REF&per_page=1&status=completed" --jq '.workflow_runs[0] // empty')
if [ -n "$RUN_JSON" ]; then
RUN_ID=$(echo "$RUN_JSON" | jq -r '.id')
CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion')
RUN_SHA=$(echo "$RUN_JSON" | jq -r '.head_sha')
echo "Found PR Tests run #$RUN_ID (conclusion: $CONCLUSION, sha: $RUN_SHA)"
if [ "$RUN_SHA" = "$HEAD_SHA" ] && [ "$CONCLUSION" = "success" ]; then
echo "tests_passed=true" >> $GITHUB_OUTPUT
else
echo "tests_passed=false" >> $GITHUB_OUTPUT
fi
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
else
echo "No completed PR Tests run found"
echo "tests_passed=false" >> $GITHUB_OUTPUT
echo "run_id=${{ github.run_id }}" >> $GITHUB_OUTPUT
fi
- name: Get PR from workflow run or dispatch inputs
uses: actions/github-script@v7
id: get-pr
if: github.event_name != 'issue_comment' || steps.comment-gate.outputs.valid == 'true'
with:
script: |
// Check if this is a workflow_dispatch event with inputs
const eventName = context.eventName;
let headBranch, headRepo, prNumber;
if (eventName === 'workflow_dispatch') {
// Use inputs from workflow_dispatch
headBranch = '${{ inputs.head_ref }}';
headRepo = '${{ inputs.head_repo }}';
prNumber = parseInt('${{ inputs.pr_number }}', 10);
console.log(`Triggered via workflow_dispatch for PR #${prNumber}`);
} else if (eventName === 'issue_comment') {
// Triggered via /review comment on a PR
prNumber = context.payload.issue.number;
console.log(`Triggered via /review comment on PR #${prNumber}`);
} else if (eventName === 'pull_request') {
// Use data from pull_request event (triggered on close)
const pr = context.payload.pull_request;
headBranch = pr.head.ref;
headRepo = pr.head.repo.full_name;
prNumber = pr.number;
console.log(`Triggered via pull_request event for PR #${prNumber}`);
} else {
// Use data from workflow_run event
headBranch = context.payload.workflow_run.head_branch;
headRepo = context.payload.workflow_run.head_repository.full_name;
console.log(`Looking for PR with head branch: ${headBranch} from ${headRepo}`);
}
// Fetch PR details
let pr;
if (prNumber) {
// Fetch specific PR by number
const prResponse = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
pr = prResponse.data;
} else {
// Search for PR by head branch
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${headRepo.split('/')[0]}:${headBranch}`
});
if (prs.data.length > 0) {
pr = prs.data[0];
}
}
if (pr) {
console.log(`Found PR #${pr.number}: ${pr.title}`);
core.setOutput('pr_number', pr.number);
core.setOutput('pr_title', pr.title);
// Base64 encode PR body to avoid issues with special characters in env vars
const prBodyB64 = Buffer.from(pr.body || '').toString('base64');
core.setOutput('pr_body_b64', prBodyB64);
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
core.setOutput('is_draft', pr.draft ? 'true' : 'false');
} else {
console.log('No open PR found for this workflow run');
core.setOutput('pr_number', '');
core.setOutput('is_draft', 'false');
}
- name: Resolve reviewed SHA
id: resolve-reviewed-sha
if: steps.get-pr.outputs.pr_number != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "issue_comment" ]; then
REVIEWED_SHA="${{ steps.lookup-tests.outputs.head_sha }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
REVIEWED_SHA="${{ github.event.pull_request.head.sha }}"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
REVIEWED_SHA="${{ inputs.reviewed_sha }}"
if [ -z "$REVIEWED_SHA" ]; then
REVIEWED_SHA=$(gh api repos/${{ github.repository }}/actions/runs/${{ inputs.run_id }} --jq '.head_sha')
fi
else
REVIEWED_SHA="${{ github.event.workflow_run.head_sha }}"
fi
if [ -z "$REVIEWED_SHA" ] || [ "$REVIEWED_SHA" = "null" ]; then
echo "Unable to determine immutable reviewed SHA" >&2
exit 1
fi
echo "reviewed_sha=$REVIEWED_SHA" >> "$GITHUB_OUTPUT"
echo "Resolved reviewed SHA: $REVIEWED_SHA"
- name: Extract linked issue from PR body
if: steps.get-pr.outputs.pr_number != ''
id: get-issue
env:
PR_BODY_B64: ${{ steps.get-pr.outputs.pr_body_b64 }}
GH_TOKEN: ${{ github.token }}
run: |
# Decode PR body to search for linked issues
PR_BODY=$(echo "$PR_BODY_B64" | base64 -d)
# Parse PR body for "Resolves #X", "Fixes #X", "Closes #X" patterns
ISSUE_NUM=$(echo "$PR_BODY" | grep -oP '(Resolves|Fixes|Closes|Fix)\s*#\K\d+' | head -1 || echo "")
echo "issue_number=$ISSUE_NUM" >> $GITHUB_OUTPUT
if [ -n "$ISSUE_NUM" ]; then
echo "Found linked issue: #$ISSUE_NUM"
# Fetch issue details
ISSUE_JSON=$(gh api repos/${{ github.repository }}/issues/$ISSUE_NUM 2>/dev/null || echo "{}")
ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title // ""')
ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')
echo "issue_title=$ISSUE_TITLE" >> $GITHUB_OUTPUT
# Base64 encode issue body to avoid issues with special characters
echo "issue_body_b64=$(echo "$ISSUE_BODY" | base64 -w0)" >> $GITHUB_OUTPUT
else
echo "No linked issue found in PR body"
echo "issue_title=" >> $GITHUB_OUTPUT
echo "issue_body_b64=" >> $GITHUB_OUTPUT
fi
- name: Count fix attempts from PR labels
if: steps.get-pr.outputs.pr_number != ''
id: count-attempts
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ steps.get-pr.outputs.pr_number }}"
# Get all labels on the PR
LABELS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels --jq '.[].name' 2>/dev/null || echo "")
# Count codex-fix-attempt-N labels
COUNT=0
for label in $LABELS; do
if [[ "$label" =~ ^codex-fix-attempt-[0-9]+$ ]]; then
NUM=$(echo "$label" | grep -oP '\d+$')
if [ "$NUM" -gt "$COUNT" ]; then
COUNT=$NUM
fi
fi
done
echo "Current fix attempt count: $COUNT"
echo "count=$COUNT" >> $GITHUB_OUTPUT
- name: Check if same-SHA reviews already passed (or reset on /review)
if: steps.get-pr.outputs.pr_number != ''
id: check-existing-reviews
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ steps.get-pr.outputs.pr_number }}"
EVENT_NAME="${{ github.event_name }}"
REVIEWED_SHA="${{ steps.resolve-reviewed-sha.outputs.reviewed_sha }}"
HEAD_REPO="${{ steps.get-pr.outputs.head_repo }}"
# Fast-path: if PR Tests completed for a head SHA that the merge-decision
# job already marked as GO-CLEAN with no re-review required, skip this
# workflow_run invocation entirely. Manual /review remains authoritative
# because it uses the issue_comment path and bypasses this shortcut.
if [ "$EVENT_NAME" = "workflow_run" ] && [ "${{ github.event.workflow_run.conclusion }}" = "success" ]; then
EXISTING_GO_CLEAN_STATUS=$(gh api repos/$HEAD_REPO/commits/$REVIEWED_SHA/status \
--jq '
[.statuses[]
| select(
.context == "All Required Agent Reviews" and
.state == "success" and
.description == "Merge decision: GO-CLEAN (no re-review required)"
)]
| length
' 2>/dev/null || echo "0")
if [ "$EXISTING_GO_CLEAN_STATUS" -gt 0 ]; then
echo "Current head already has merge-decision GO-CLEAN approval - skipping re-review"
echo "already_passed=true" >> $GITHUB_OUTPUT
echo "skip_reason=same_sha_go_clean" >> $GITHUB_OUTPUT
exit 0
fi
fi
# If PR was closed, strip all review labels so a future reopen starts clean
if [ "$EVENT_NAME" = "pull_request" ] && [ "${{ github.event.action }}" = "closed" ]; then
echo "PR was closed - removing all review progress labels"
for label in \
"agent-reviews-passed" \
"all-reviews-started" \
"design-review-started" \
"security-review-started" \
"psych-review-started" \
"docs-review-started" \
"prompt-review-started" \
"merge-decision-started"; do
gh pr edit $PR_NUMBER --remove-label "$label" --repo ${{ github.repository }} 2>/dev/null || true
done
# Remove review-cycle-* labels so reopened PRs start fresh
for label in $(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels \
--jq '.[].name | select(test("^review-cycle-[0-9]+$"))' 2>/dev/null || true); do
gh pr edit $PR_NUMBER --remove-label "$label" --repo ${{ github.repository }} 2>/dev/null || true
done
# Signal already_passed=true to skip all downstream review jobs
echo "already_passed=true" >> $GITHUB_OUTPUT
echo "skip_reason=pr_closed" >> $GITHUB_OUTPUT
exit 0
fi
# If /review comment, remove all review progress labels to allow re-review
if [ "$EVENT_NAME" = "issue_comment" ]; then
echo "/review comment - removing review progress labels to allow re-review"
for label in \
"agent-reviews-passed" \
"all-reviews-started" \
"design-review-started" \
"security-review-started" \
"psych-review-started" \
"docs-review-started" \
"prompt-review-started" \
"merge-decision-started"; do
gh pr edit $PR_NUMBER --remove-label "$label" --repo ${{ github.repository }} 2>/dev/null || true
done
# Reset review-cycle-* labels so /review gets a fresh cycle count
for label in $(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels \
--jq '.[].name | select(test("^review-cycle-[0-9]+$"))' 2>/dev/null || true); do
gh pr edit $PR_NUMBER --remove-label "$label" --repo ${{ github.repository }} 2>/dev/null || true
done
echo "already_passed=false" >> $GITHUB_OUTPUT
echo "skip_reason=manual_rerun" >> $GITHUB_OUTPUT
exit 0
fi
# Do not use PR-scoped labels to skip reviews. A new head SHA must run
# the normal review path unless that exact SHA already has a same-SHA
# GO-CLEAN approval published by merge-decision above.
echo "No same-SHA prior approval found - will run full review cycle"
echo "already_passed=false" >> $GITHUB_OUTPUT
echo "skip_reason=" >> $GITHUB_OUTPUT
- name: Resolve review cycle and enforce cap
id: resolve-review-cycle
if: steps.get-pr.outputs.pr_number != '' && steps.check-existing-reviews.outputs.already_passed != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
MAX_REVIEW_CYCLES=2
PR_NUMBER="${{ steps.get-pr.outputs.pr_number }}"
# Count existing review-cycle-N labels after any /review or close-event reset.
if ! LABELS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels --jq '.[].name'); then
echo "Failed to read review-cycle labels for PR #$PR_NUMBER" >&2
exit 1
fi
COUNT=0
for label in $LABELS; do
if [[ "$label" =~ ^review-cycle-[0-9]+$ ]]; then
COUNT=$((COUNT + 1))
fi
done
# First cycle: stamp review-cycle-1. Later cycles are stamped only after a
# follow-up review run has actually been dispatched.
if [ "$COUNT" -eq 0 ]; then
COUNT=1
gh label create "review-cycle-1" \
--color "1d76db" \
--description "Review cycle 1 started" \
--repo ${{ github.repository }} 2>/dev/null || true
gh pr edit "$PR_NUMBER" --add-label "review-cycle-1" \
--repo ${{ github.repository }}
echo "Added label: review-cycle-1"
fi
echo "Review cycle: $COUNT / $MAX_REVIEW_CYCLES"
echo "review_cycle=$COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" -gt "$MAX_REVIEW_CYCLES" ]; then
echo "::warning::Review cycle cap exceeded ($COUNT > $MAX_REVIEW_CYCLES) — skipping review jobs"
echo "capped=true" >> "$GITHUB_OUTPUT"
else
echo "capped=false" >> "$GITHUB_OUTPUT"
fi
# SECURITY: Check if PR author is authorized to trigger Codex reviews
# This prevents prompt injection and code execution from external/untrusted users
- name: Check PR author authorization
if: steps.get-pr.outputs.pr_number != ''
id: check-author-auth
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ steps.get-pr.outputs.pr_number }}"
# Fetch PR author association
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
AUTHOR_ASSOC=$(echo "$PR_DATA" | jq -r '.author_association')
AUTHOR_LOGIN=$(echo "$PR_DATA" | jq -r '.user.login')
HEAD_REPO=$(echo "$PR_DATA" | jq -r '.head.repo.full_name')
BASE_REPO=$(echo "$PR_DATA" | jq -r '.base.repo.full_name')
echo "PR Author: $AUTHOR_LOGIN"
echo "Author association: $AUTHOR_ASSOC"
echo "Head repo: $HEAD_REPO"
echo "Base repo: $BASE_REPO"
# Check if this is a fork PR (external contribution)
IS_FORK="false"
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
IS_FORK="true"
echo "This is a PR from a fork: $HEAD_REPO -> $BASE_REPO"
fi
# Allow: OWNER, MEMBER, COLLABORATOR
# Deny: CONTRIBUTOR, FIRST_TIMER, FIRST_TIME_CONTRIBUTOR, MANNEQUIN, NONE
case "$AUTHOR_ASSOC" in
OWNER|MEMBER|COLLABORATOR)
echo "Authorized: $AUTHOR_LOGIN is a $AUTHOR_ASSOC"
echo "authorized=true" >> $GITHUB_OUTPUT
;;
*)
echo "Not authorized: $AUTHOR_LOGIN is a $AUTHOR_ASSOC"
echo "External users cannot trigger Codex Code Review workflows."
echo "This is a security measure to prevent prompt injection attacks."
echo "authorized=false" >> $GITHUB_OUTPUT
# Post a comment explaining why the review was skipped
gh pr comment $PR_NUMBER --body "## Codex Code Review Skipped
This PR was opened by an external contributor ($AUTHOR_LOGIN with association: $AUTHOR_ASSOC).
For security reasons, Codex Code reviews are only enabled for repository collaborators, members, and owners. This prevents potential prompt injection attacks through PR descriptions or issue content.
A repository maintainer will review this PR manually.
---
This is an automated security notice." --repo ${{ github.repository }} 2>/dev/null || true
;;
esac
- name: Classify PR files
if: steps.get-pr.outputs.pr_number != ''
id: classify-files
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ steps.get-pr.outputs.pr_number }}"
FILES=$(gh pr diff $PR_NUMBER --name-only --repo ${{ github.repository }})
echo "Changed files:"
echo "$FILES"
CONFIG_ONLY="true"
HAS_SCRIPTS="false"
DOCS_ONLY="true"
while IFS= read -r file; do
[ -z "$file" ] && continue
if [[ "$file" == *.sh ]]; then
HAS_SCRIPTS="true"
fi
if [[ "$file" == scripts/* ]] || [[ "$file" == docs/* ]] || [[ "$file" == design/* ]] || [[ "$file" == *.md ]]; then
CONFIG_ONLY="false"
fi
# docs_only: true when all changed files are inert documentation
# In this agentic system, most .md files ARE the application (prompts,
# cron jobs, agent identity, behavior specs). Only design/* (research
# principles) and README.md files are truly non-behavioral.
if [[ "$file" != design/* ]] && [[ "$(basename "$file")" != "README.md" ]]; then
DOCS_ONLY="false"
fi
done <<< "$FILES"
echo "Classification: config_only=$CONFIG_ONLY has_scripts=$HAS_SCRIPTS docs_only=$DOCS_ONLY"
echo "config_only=$CONFIG_ONLY" >> $GITHUB_OUTPUT
echo "has_scripts=$HAS_SCRIPTS" >> $GITHUB_OUTPUT
echo "docs_only=$DOCS_ONLY" >> $GITHUB_OUTPUT
# Build and cache devcontainer image
# SECURITY: Always build from main branch, NEVER from PR branches
# This prevents malicious Dockerfile modifications from being used to build the container
build-devcontainer:
runs-on: [self-hosted, homelab]
needs: get-context
if: |
needs.get-context.outputs.pr_number != '' &&
needs.get-context.outputs.is_draft != 'true' &&
needs.get-context.outputs.reviews_already_passed != 'true' &&
needs.get-context.outputs.author_authorized == 'true'
permissions:
contents: read
packages: write
steps:
# SECURITY: Checkout main branch, NOT the PR branch
# This ensures we never build a devcontainer from potentially malicious PR code
- name: Checkout main branch (security measure)
uses: actions/checkout@v4
with:
ref: main
- name: Create directories for devcontainer mounts
run: mkdir -p ~/.config/gh ~/.claude ~/.codex
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push devcontainer
id: build_devcontainer
uses: devcontainers/ci@v0.3
# continue-on-error: Post-action cleanup may fail on GHCR push
# even when the image builds successfully. Downstream review jobs
# will fail independently if the image is actually unavailable.
continue-on-error: true
with:
imageName: ${{ env.DEVCONTAINER_IMAGE }}
cacheFrom: ${{ env.DEVCONTAINER_IMAGE }}
push: always
- name: Validate devcontainer image availability
if: steps.build_devcontainer.outcome != 'success'
run: |
echo "Build step did not succeed (outcome: ${{ steps.build_devcontainer.outcome }})"
echo "Checking if the image is available in the registry..."
if docker manifest inspect "${{ env.DEVCONTAINER_IMAGE }}" > /dev/null 2>&1; then
echo "Image is available — the build likely succeeded but post-step cleanup crashed."
else
echo "Image is NOT available — the build genuinely failed."
exit 1
fi
# Fix test failures - runs in a retry loop until tests pass (max 3 attempts)
fix-test-failures:
needs: [get-context, build-devcontainer]
if: |
needs.get-context.outputs.pr_number != '' &&
needs.get-context.outputs.is_draft != 'true' &&
needs.get-context.outputs.reviews_already_passed != 'true' &&
needs.get-context.outputs.author_authorized == 'true' &&
needs.get-context.outputs.tests_passed == 'false' &&
needs.get-context.outputs.fix_attempts < 3
runs-on: [self-hosted, homelab]
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
packages: read
actions: write # Required to trigger PR Tests workflow via workflow_dispatch
statuses: write # Required to create commit status after tests pass
steps:
- name: Checkout main for local actions
uses: actions/checkout@v4
with:
ref: main
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ needs.get-context.outputs.head_ref }}
repository: ${{ needs.get-context.outputs.head_repo }}
path: ${{ env.PR_WORKSPACE_PATH }}
fetch-depth: 0
token: ${{ secrets.WORKFLOW_PAT }} # PAT required to push workflow file changes
- name: Increment fix attempt counter
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ needs.get-context.outputs.pr_number }}"
CURRENT_ATTEMPTS="${{ needs.get-context.outputs.fix_attempts }}"
NEXT_ATTEMPT=$((CURRENT_ATTEMPTS + 1))
# Create the label if it doesn't exist
gh label create "codex-fix-attempt-$NEXT_ATTEMPT" \
--color "ff9500" \
--description "Codex fix attempt $NEXT_ATTEMPT" \
--repo ${{ github.repository }} 2>/dev/null || true
# Add the label to the PR
gh pr edit $PR_NUMBER --add-label "codex-fix-attempt-$NEXT_ATTEMPT" --repo ${{ github.repository }}
echo "Marked as fix attempt $NEXT_ATTEMPT"
- name: Create directories for devcontainer mounts
run: mkdir -p ~/.config/gh ~/.claude ~/.codex
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Codex to fix test failures
id: codex-fix
uses: ./.github/actions/run-devcontainer
with:
image: ${{ env.DEVCONTAINER_IMAGE }}
workspace_folder: ${{ env.PR_WORKSPACE_PATH }}
pull: 'true'
env: |
OPENAI_API_KEY=fake-key
GH_TOKEN=${{ secrets.WORKFLOW_PAT }}
PR_NUMBER=${{ needs.get-context.outputs.pr_number }}
PR_TITLE=${{ needs.get-context.outputs.pr_title }}
PR_BODY_B64=${{ needs.get-context.outputs.pr_body_b64 }}
ISSUE_NUMBER=${{ needs.get-context.outputs.issue_number }}
ISSUE_TITLE=${{ needs.get-context.outputs.issue_title }}
ISSUE_BODY_B64=${{ needs.get-context.outputs.issue_body_b64 }}
RUN_ID=${{ needs.get-context.outputs.run_id }}
ATTEMPT_NUM=${{ needs.get-context.outputs.fix_attempts }}
REPO=${{ github.repository }}
TRIGGERING_SHA=${{ needs.get-context.outputs.triggering_sha }}
HEAD_REF=${{ needs.get-context.outputs.head_ref }}
run_cmd: |
# Calculate actual attempt number (current + 1)
ATTEMPT=$((ATTEMPT_NUM + 1))
# Decode base64-encoded bodies (handles special characters safely)
PR_BODY=$(echo "$PR_BODY_B64" | base64 -d 2>/dev/null || echo "")
ISSUE_BODY=$(echo "$ISSUE_BODY_B64" | base64 -d 2>/dev/null || echo "")
source .devcontainer/configure-codex.sh
# Run Codex to fix test failures
timeout 30m codex exec \
--json \
--dangerously-bypass-approvals-and-sandbox \
"You are fixing CI test failures for PR #${PR_NUMBER} in ${REPO}.
This is fix attempt ${ATTEMPT} of 3.
CRITICAL: BEFORE PUSHING ANY CHANGES, you MUST check if the PR author has pushed
newer commits since this workflow started. Run:
git fetch origin ${HEAD_REF}
CURRENT_HEAD=\$(git rev-parse origin/${HEAD_REF})
if [ \"\$CURRENT_HEAD\" != \"${TRIGGERING_SHA}\" ]; then
echo 'Author has pushed newer commits - attempting to merge'
# Try to rebase our changes on top of the author's commits
git stash 2>/dev/null || true
if git pull --rebase origin ${HEAD_REF}; then
git stash pop 2>/dev/null || true
echo 'Successfully rebased on top of author commits - continuing with fix'
else
# Rebase failed - abort and let author handle it
git rebase --abort 2>/dev/null || true
git stash pop 2>/dev/null || true
gh pr comment ${PR_NUMBER} --body '## Fix Attempt ${ATTEMPT}/3 - Aborted
Detected newer commits pushed by author. Attempted to merge but encountered conflicts.
Skipping automated fix to avoid overwriting author changes.
The author is likely fixing this themselves.'
exit 0
fi
fi
Proceed with fixing:
ORIGINAL ISSUE (if applicable):
Issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}
${ISSUE_BODY}
PR DESCRIPTION:
${PR_TITLE}
${PR_BODY}
The tests failed in the PR Tests workflow (run ID: ${RUN_ID}). Your task:
1. Check the workflow run logs: gh run view ${RUN_ID} --log-failed
2. Identify the root cause of failures (failing tests, lint warnings, validation issues, etc.)
3. Fix the issues in the code
4. Run \`shellcheck scripts/*.sh\` and \`yamllint .github/workflows/*.yml\` to verify your fix works
5. Commit and push your fixes
IMPORTANT RULES FOR THIS REPOSITORY:
- This is an OpenClaw agent project, not a compiled application
- Use \`shellcheck scripts/*.sh\` for shell script linting
- Use \`yamllint .github/workflows/*.yml\` for workflow validation
- Check documentation links are not broken
- Never use \`git push --no-verify\`
- If this is attempt 2+, review what was tried before and try a different approach
After pushing, post a brief status update:
gh pr comment ${PR_NUMBER} --body '## Fix Attempt ${ATTEMPT}/3
[Describe what you found and fixed]
Pushed fix - triggering new test run.'" < /dev/null 2>&1 | tee /tmp/fix-failures-output.jsonl
- name: Trigger PR Tests and reviews after fix
if: success()
working-directory: ${{ env.PR_WORKSPACE_PATH }}
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
STATUS_API_TOKEN: ${{ github.token }}
FIX_ATTEMPTS: ${{ needs.get-context.outputs.fix_attempts }}
run: |
# Check if new commits were pushed by comparing current HEAD to triggering SHA
git fetch origin ${{ needs.get-context.outputs.head_ref }}
CURRENT_HEAD=$(git rev-parse origin/${{ needs.get-context.outputs.head_ref }})
TRIGGERING_SHA="${{ needs.get-context.outputs.triggering_sha }}"
if [ "$CURRENT_HEAD" != "$TRIGGERING_SHA" ]; then
echo "Fix was pushed (HEAD changed from $TRIGGERING_SHA to $CURRENT_HEAD)"
echo "Triggering PR Tests workflow..."
# Trigger PR Tests and capture the run
gh workflow run "PR Tests" --ref "${{ needs.get-context.outputs.head_ref }}" --repo ${{ github.repository }}
echo "PR Tests workflow triggered, waiting for it to start..."
sleep 10
# Find the run we just triggered
RUN_ID=$(gh run list --workflow="pr-tests.yml" --branch="${{ needs.get-context.outputs.head_ref }}" --limit=1 --json databaseId --jq '.[0].databaseId')
echo "Found PR Tests run: $RUN_ID"
# Poll for completion (max 15 minutes)
MAX_WAIT=900
ELAPSED=0
POLL_INTERVAL=30
while [ $ELAPSED -lt $MAX_WAIT ]; do
STATUS=$(gh run view $RUN_ID --json status,conclusion --jq '.status')
if [ "$STATUS" = "completed" ]; then
CONCLUSION=$(gh run view $RUN_ID --json conclusion --jq '.conclusion')
echo "PR Tests completed with conclusion: $CONCLUSION"
break
fi
echo "PR Tests still running... (waited ${ELAPSED}s)"
sleep $POLL_INTERVAL
ELAPSED=$((ELAPSED + POLL_INTERVAL))
done
if [ $ELAPSED -ge $MAX_WAIT ]; then
echo "Timed out waiting for PR Tests - reviews will not run automatically"
exit 0
fi
# If tests passed, create a commit status and trigger reviews
# (workflow_run events don't fire for workflow_dispatch-triggered runs)
if [ "$CONCLUSION" = "success" ]; then
echo "Tests passed!"
# Create a commit status for the PR's head SHA so the PR check shows as passed
# This is needed because workflow_dispatch runs don't automatically create PR status checks
echo "Creating commit status for $CURRENT_HEAD..."
GH_TOKEN="$STATUS_API_TOKEN" \
gh api \
repos/${{ github.repository }}/statuses/$CURRENT_HEAD \
-f state="success" \
-f target_url="https://github.com/${{ github.repository }}/actions/runs/$RUN_ID" \
-f description="All required tests passed (via automated fix)" \
-f context="All Required Tests"
echo "Commit status created successfully"
echo "Triggering Codex Code Review..."
gh workflow run "Codex Code Review" \
--ref "main" \
--repo ${{ github.repository }} \
-f head_ref="${{ needs.get-context.outputs.head_ref }}" \
-f head_repo="${{ needs.get-context.outputs.head_repo }}" \
-f pr_number="${{ needs.get-context.outputs.pr_number }}" \
-f tests_passed="true" \
-f run_id="$RUN_ID" \
-f reviewed_sha="$CURRENT_HEAD"
echo "Codex Code Review workflow triggered successfully"
else
echo "Tests still failing after fix attempt"
# Trigger another Codex Code Review run to retry the fix
# This is necessary because workflow_run events don't fire for
# workflow_dispatch-triggered runs, so we must manually trigger
NEXT_ATTEMPT=$((FIX_ATTEMPTS + 1))
if [ "$NEXT_ATTEMPT" -lt 3 ]; then
echo "Triggering fix attempt $((NEXT_ATTEMPT + 1))/3..."
gh workflow run "Codex Code Review" \
--ref "main" \
--repo ${{ github.repository }} \
-f head_ref="${{ needs.get-context.outputs.head_ref }}" \
-f head_repo="${{ needs.get-context.outputs.head_repo }}" \
-f pr_number="${{ needs.get-context.outputs.pr_number }}" \
-f tests_passed="false" \
-f run_id="$RUN_ID" \
-f reviewed_sha="$CURRENT_HEAD"
echo "Next fix attempt triggered"
else
echo "Max fix attempts (3) reached - human intervention required"
COMMENT_BODY="## Automated Fix Failed"
COMMENT_BODY="$COMMENT_BODY"$'\n\n'"All 3 automated fix attempts were unsuccessful. Human intervention is required."
COMMENT_BODY="$COMMENT_BODY"$'\n\n'"**Recent Test Run:** https://github.com/${{ github.repository }}/actions/runs/$RUN_ID"
COMMENT_BODY="$COMMENT_BODY"$'\n\n'"Generated by Codex Code Review workflow"
gh pr comment ${{ needs.get-context.outputs.pr_number }} --body "$COMMENT_BODY"
fi
fi
else
echo "No new commits - Codex may have aborted or found no fix needed"
fi
- name: Upload fix attempt output
uses: actions/upload-artifact@v4
if: always()
continue-on-error: true
with:
name: fix-failures-output-attempt-${{ needs.get-context.outputs.fix_attempts }}
path: /tmp/fix-failures-output.jsonl
retention-days: 7
# Design review specialist - validates PR implements issue intent and reviews design quality
# Runs FIRST (before code review) because intent and design must be validated before code quality
design-review:
needs: [get-context, build-devcontainer]
if: |
needs.get-context.outputs.pr_number != '' &&
needs.get-context.outputs.is_draft != 'true' &&
needs.get-context.outputs.reviews_already_passed != 'true' &&
needs.get-context.outputs.review_cycle_capped != 'true' &&
needs.get-context.outputs.author_authorized == 'true' &&
needs.get-context.outputs.tests_passed == 'true'
runs-on: [self-hosted, homelab]
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
packages: read
steps:
- name: Checkout main for composite actions
uses: actions/checkout@v4
with:
ref: main
- name: Reviewer setup
id: setup
uses: ./.github/actions/reviewer-setup
with:
pr_number: ${{ needs.get-context.outputs.pr_number }}
head_ref: ${{ needs.get-context.outputs.head_ref }}
head_repo: ${{ needs.get-context.outputs.head_repo }}
github_token: ${{ github.token }}
repository: ${{ github.repository }}
pr_tests_run_id: ${{ needs.get-context.outputs.pr_tests_run_id }}
- name: Mark design review started
if: steps.setup.outputs.verified == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ needs.get-context.outputs.pr_number }}"
STARTED_LABEL="design-review-started"
REQUIRED_STARTED_LABELS=$(cat <<'EOF'
design-review-started
docs-review-started
EOF
)
if [ "${{ needs.get-context.outputs.docs_only }}" != "true" ]; then
REQUIRED_STARTED_LABELS="${REQUIRED_STARTED_LABELS}"$'\n'"security-review-started"
fi
if [ "${{ needs.get-context.outputs.config_only }}" != "true" ]; then
REQUIRED_STARTED_LABELS="${REQUIRED_STARTED_LABELS}"$'\n'"psych-review-started"$'\n'"prompt-review-started"
fi
gh label create "$STARTED_LABEL" \
--color "c2e0c6" \
--description "Design review has started" \
--repo ${{ github.repository }} 2>/dev/null || true
gh pr edit $PR_NUMBER --add-label "$STARTED_LABEL" --repo ${{ github.repository }}
LABELS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels --jq '.[].name' 2>/dev/null || echo "")
ALL_STARTED=true
while IFS= read -r label; do
[ -z "$label" ] && continue
if ! echo "$LABELS" | grep -qx "$label"; then