-
Notifications
You must be signed in to change notification settings - Fork 0
1659 lines (1439 loc) · 75.9 KB
/
claude-code-review.yml
File metadata and controls
1659 lines (1439 loc) · 75.9 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: Claude Code Review
# Dynamic run name shows PR number for easy identification in the Actions UI
# Format: "Claude Code Review (PR #123)" or "Claude Code Review (branch-name)"
run-name: >-
${{ github.event_name == 'workflow_dispatch' && format('Claude Code Review (PR #{0})', inputs.pr_number) ||
github.event_name == 'pull_request' && format('Claude Code Review (PR #{0})', github.event.pull_request.number) ||
format('Claude Code Review ({0})', github.event.workflow_run.head_branch) }}
# SECURITY NOTE: This workflow grants Claude Code 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 Claude reviews
# - The devcontainer is NEVER rebuilt from PR branches to prevent malicious Dockerfile injection
# - External PRs (from forks) are blocked from triggering Claude
on:
workflow_run:
workflows: ["PR Tests"]
types: [completed]
# Reset reviews when PR is reopened (allows re-triggering full review cycle)
pull_request:
types: [reopened]
# 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
# 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: claude-review-${{ github.event_name == 'workflow_dispatch' && inputs.head_ref || 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
jobs:
# Extract PR and Issue context from workflow_run or workflow_dispatch event
get-context:
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.
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, and pull_request events
# For pull_request (reopen), tests haven't run yet so tests_passed is false
tests_passed: ${{ github.event_name == 'workflow_dispatch' && inputs.tests_passed == 'true' || 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 == 'pull_request' && github.run_id || github.event.workflow_run.id }}
# SHA that triggered this workflow - used to detect if author pushed newer commits
triggering_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.event.workflow_run.head_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 }}
# 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 }}
steps:
- name: Get PR from workflow run or dispatch inputs
uses: actions/github-script@v7
id: get-pr
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 === 'pull_request') {
// Use data from pull_request event (triggered on reopen)
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: 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 claude-fix-attempt-N labels
COUNT=0
for label in $LABELS; do
if [[ "$label" =~ ^claude-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 reviews already passed (or reset on reopen)
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 }}"
# If PR was reopened, remove the reviews-passed label to allow re-review
if [ "$EVENT_NAME" = "pull_request" ] && [ "${{ github.event.action }}" = "reopened" ]; then
echo "PR was reopened - removing agent-reviews-passed label to allow re-review"
gh pr edit $PR_NUMBER --remove-label "agent-reviews-passed" --repo ${{ github.repository }} 2>/dev/null || true
echo "already_passed=false" >> $GITHUB_OUTPUT
exit 0
fi
# Check if agent-reviews-passed label exists
LABELS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels --jq '.[].name' 2>/dev/null || echo "")
if echo "$LABELS" | grep -q "^agent-reviews-passed$"; then
echo "Agent reviews already passed for this PR - skipping review cycle"
echo "already_passed=true" >> $GITHUB_OUTPUT
else
echo "No existing reviews found - will run full review cycle"
echo "already_passed=false" >> $GITHUB_OUTPUT
fi
# SECURITY: Check if PR author is authorized to trigger Claude 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 Claude 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 "## Claude Code Review Skipped
This PR was opened by an external contributor ($AUTHOR_LOGIN with association: $AUTHOR_ASSOC).
For security reasons, Claude 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 *.md, docs/*, or design/*
if [[ "$file" != *.md ]] && [[ "$file" != docs/* ]] && [[ "$file" != design/* ]]; 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
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
# 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 PR branch
uses: actions/checkout@v4
with:
ref: ${{ needs.get-context.outputs.head_ref }}
repository: ${{ needs.get-context.outputs.head_repo }}
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 "claude-fix-attempt-$NEXT_ATTEMPT" \
--color "ff9500" \
--description "Claude fix attempt $NEXT_ATTEMPT" \
--repo ${{ github.repository }} 2>/dev/null || true
# Add the label to the PR
gh pr edit $PR_NUMBER --add-label "claude-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 Claude to fix test failures
id: claude-fix
uses: devcontainers/ci@v0.3
with:
imageName: ${{ env.DEVCONTAINER_IMAGE }}
cacheFrom: ${{ env.DEVCONTAINER_IMAGE }}
push: never
env: |
CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
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 }}
runCmd: |
# 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 "")
# Run Claude Code to fix test failures
# Using Sonnet: Task is debugging with clear error messages and 3 retry attempts
claude --print \
--verbose \
--output-format stream-json \
--model sonnet \
--dangerously-skip-permissions \
--max-turns 200 \
"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()
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
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 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 Claude Code Review..."
gh workflow run "Claude 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"
echo "Claude Code Review workflow triggered successfully"
else
echo "Tests still failing after fix attempt"
# Trigger another Claude 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 "Claude 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"
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 Claude Code Review workflow"
gh pr comment ${{ needs.get-context.outputs.pr_number }} --body "$COMMENT_BODY"
fi
fi
else
echo "No new commits - Claude 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.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 for composite action
uses: actions/checkout@v4
- 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 }}
- name: Design review in devcontainer
if: steps.setup.outputs.verified == 'true'
uses: devcontainers/ci@v0.3
with:
imageName: ${{ env.DEVCONTAINER_IMAGE }}
cacheFrom: ${{ env.DEVCONTAINER_IMAGE }}
push: never
env: |
CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
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 }}
HEAD_REF=${{ needs.get-context.outputs.head_ref }}
REPO=${{ github.repository }}
runCmd: |
# 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 "")
# Fetch all comments on the linked issue (may contain additional context/requirements)
ISSUE_COMMENTS=""
if [ -n "$ISSUE_NUMBER" ]; then
ISSUE_COMMENTS=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" --jq '.[] | "---\n**\(.user.login)** commented at \(.created_at):\n\(.body)\n"' 2>/dev/null | head -c 50000 || echo "")
fi
claude --print \
--verbose \
--output-format stream-json \
--model opus \
--dangerously-skip-permissions \
--max-turns 450 \
"You are a DESIGN REVIEW specialist for PR #${PR_NUMBER} in ${REPO}.
Review like an experienced staff engineer. Be direct and selective.
Don't praise the design or list strengths — focus on what you'd actually flag in a review.
Non-blocking observations are welcome but keep each to one sentence.
If the design is sound, approve in one line and move on.
**CONTEXT:**
PR Title: ${PR_TITLE}
PR Description:
${PR_BODY}
Linked Issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}
Issue Description:
${ISSUE_BODY}
Issue Comments (additional context/requirements):
${ISSUE_COMMENTS}
**YOUR TASK:**
1. Read the issue/PR description to understand the problem being solved.
2. Examine the code changes (\`git diff origin/main...HEAD\`).
3. Validate: does the PR solve the stated problem? Gaps between intent and delivery are blocking.
4. SCOPE CHECK: Compare the PR title/issue against the actual diff.
- If the title suggests a narrow fix but the diff introduces new abstractions or
significant code beyond what the title implies, flag as blocking scope creep.
- State explicitly: \"Scope check: PASS\" or \"Scope check: FAIL — [reason]\"
5. Evaluate design decisions: could a simpler approach work? Flag over-engineering as blocking.
Reference docs/architecture.md for system design context.
Do NOT push any changes. This is a read-only review.
**INLINE COMMENTS:** Where a concern is tied to a specific line of code, also leave
an inline PR review comment on that line. Use inline comments for localized feedback
and the summary comment for overall assessment.
Post exactly ONE summary comment using:
gh pr comment ${PR_NUMBER} --body '## Design Review
### Blocking Issues
[List blocking concerns. Skip section if none.]
### Worth Considering
[One-sentence non-blocking observations. Skip section if none.]
### Conclusion
[Approved | Approved with reservations | Needs revision — [reason]]'" < /dev/null 2>&1 | tee /tmp/design-review-output.jsonl
- name: Upload design review output
uses: actions/upload-artifact@v4
if: always()
continue-on-error: true
with:
name: design-review-output
path: /tmp/design-review-output.jsonl
retention-days: 7
# Security and infrastructure specialist - runs in parallel with design and psych
security-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.author_authorized == 'true' &&
needs.get-context.outputs.docs_only != '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 for composite action
uses: actions/checkout@v4
- 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 }}
- name: Security review in devcontainer
if: steps.setup.outputs.verified == 'true'
uses: devcontainers/ci@v0.3
with:
imageName: ${{ env.DEVCONTAINER_IMAGE }}
cacheFrom: ${{ env.DEVCONTAINER_IMAGE }}
push: never
env: |
CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN=${{ secrets.WORKFLOW_PAT }}
PR_NUMBER=${{ needs.get-context.outputs.pr_number }}
HEAD_REF=${{ needs.get-context.outputs.head_ref }}
runCmd: |
claude --print \
--verbose \
--output-format stream-json \
--model opus \
--dangerously-skip-permissions \
--max-turns 240 \
"You are a SECURITY and INFRASTRUCTURE specialist reviewing PR #${PR_NUMBER}.
**CRITICAL: BE BRIEF. Only report issues that require action from the PR author.**
Do NOT list files reviewed. Do NOT explain general security concepts.
If there are no actionable issues, post a short approval and move on.
Run \`git diff origin/main...HEAD\` to see changes.
Read SECURITY.md for the project's security model and threat assumptions.
Check for security and infrastructure issues:
- Credential/secret handling in scripts (no hardcoded tokens, proper .env usage)
- Input validation and injection risks (especially in webhook/API handlers)
- Shell script safety (proper quoting, error handling, set -euo pipefail)
- Workflow permissions (principle of least privilege)
- Network security (proxy bypass attempts, unexpected outbound connections)
- Notion API token handling (never logged, never committed)
- Resource exhaustion risks (unbounded loops, missing timeouts)
- Prompt injection vectors in CI/CD pipelines
Run \`shellcheck scripts/*.sh\` to verify.
Do NOT push any changes. This is a read-only review.
For HIGH SEVERITY bugs, describe the fix precisely (file path, line number, exact change needed) so the merge-decision agent can apply it.
**INLINE COMMENTS:** Where a finding is tied to a specific line of code, also leave
an inline PR review comment on that line. Use inline comments for localized findings
and the summary comment for overall assessment.
Post exactly ONE summary comment using:
gh pr comment ${PR_NUMBER} --body '## Security & Infrastructure Review
[If no issues: \"Approved - No security or infrastructure issues\" and stop here]
### Issues Found
[Only list security risks, credential leaks, injection vectors, etc. Reference file:line. Skip if none.]
### Fixes Needed
[Describe fixes precisely with file paths, line numbers, and exact changes. Skip section if none.]
### Conclusion
[Approved | Needs changes | Blocking issues]'" < /dev/null 2>&1 | tee /tmp/security-review-output.jsonl
- name: Upload security review output
uses: actions/upload-artifact@v4
if: always()
continue-on-error: true
with:
name: security-review-output
path: /tmp/security-review-output.jsonl
retention-days: 7
# Psychological research evidence reviewer - validates features against ADHD research literature
psych-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.author_authorized == 'true' &&
needs.get-context.outputs.config_only != '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 for composite action
uses: actions/checkout@v4
- 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 }}
- name: Psych research review in devcontainer
if: steps.setup.outputs.verified == 'true'
uses: devcontainers/ci@v0.3
with:
imageName: ${{ env.DEVCONTAINER_IMAGE }}
cacheFrom: ${{ env.DEVCONTAINER_IMAGE }}
push: never
env: |
CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
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 }}
HEAD_REF=${{ needs.get-context.outputs.head_ref }}
REPO=${{ github.repository }}
runCmd: |
# 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 "")
claude --print \
--verbose \
--output-format stream-json \
--model opus \
--dangerously-skip-permissions \
--max-turns 450 \
"You are a PSYCHOLOGICAL RESEARCH EVIDENCE reviewer for PR #${PR_NUMBER} in ${REPO}.
Your role is to evaluate whether this PR's changes are grounded in evidence-based
understanding of ADHD, executive function, motivation, and cognitive load. This project
is an AI-powered task manager designed to help people with ADHD live successful lives.
Do NOT modify any code or push any changes. This is a read-only review.
**CRITICAL: Only report issues that require action from the PR author.**
Do NOT provide general ADHD education. Do NOT praise features that are fine.
If there are no actionable issues, post a short approval and move on.
**QUICK CHECK:** If this PR is purely infrastructure, CI/CD, devcontainer, or workflow
configuration with no user-facing behavioral changes, post:
'Approved - No user-facing behavioral changes to evaluate against ADHD research.'
and stop.
**CONTEXT:**
PR Title: ${PR_TITLE}
PR Description:
${PR_BODY}
Linked Issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}
Issue Description:
${ISSUE_BODY}
**YOUR TASK:**
1. Read the code changes: \`git diff origin/main...HEAD\`
2. Read project documentation for context:
- docs/architecture.md (system design)
- docs/user-interactions.md (how users interact with the system)
- docs/task-lifecycle.md (task states and transitions)
- docs/user-preferences.md (user configuration options)
- docs/ai-prompts.md (AI prompt design)
- docs/reward-system.md (motivation and reward mechanics)
3. Evaluate the changes against ADHD research domains below
**EVALUATION CRITERIA (only assess domains relevant to this PR):**
1. **Executive Function Support** (Barkley model of EF deficits)
- Does this help with working memory limitations?
- Does it reduce demands on self-regulation?
- Does it support task initiation and follow-through?
- Does it externalize information that ADHD users struggle to hold internally?
2. **Emotional Regulation** (Hallowell-Ratey framework)
- Does this account for rejection sensitive dysphoria?
- Are failure states handled compassionately (no shame-inducing language)?
- Does it support emotional momentum rather than punishing inconsistency?
3. **Time Perception** (time blindness research)
- Does this help users who struggle with time estimation?
- Are deadlines and durations presented in ADHD-friendly ways?
- Does it avoid relying on users' internal sense of time passing?
4. **Motivation & Reward Systems** (variable ratio reinforcement, dopamine regulation)
- Does the reward/feedback mechanism align with ADHD motivation patterns?
- Does it leverage novelty, urgency, interest, or challenge (ADHD motivation drivers)?
- Does it avoid requiring sustained motivation for low-interest tasks?
5. **Cognitive Load Management** (working memory and attention research)
- Does this minimize the number of decisions required at any moment?
- Are information displays designed to reduce overwhelm?
- Does it chunk information appropriately?
6. **Sensory & Environmental Considerations**
- Are notifications/alerts designed for ADHD attention patterns?
- Does it avoid designs that enable hyperfocus traps?
**SEVERITY GUIDELINES:**
- **Blocking**: Only for changes that could actively harm ADHD users (e.g., shame-based
language, punishment for missed tasks, designs that increase cognitive overwhelm)
- **Non-blocking suggestion**: Improvements that would better align with research but
aren't harmful as-is