1616 !contains(github.event.pull_request.labels.*.name, 'no review') &&
1717 !contains(github.event.pull_request.labels.*.name, 'auto-pr')
1818 runs-on : ubuntu-latest
19+ concurrency :
20+ group : claude-auto-review-${{ github.event.pull_request.number }}
21+ cancel-in-progress : true
1922 permissions :
2023 id-token : write
2124 contents : read
@@ -44,11 +47,12 @@ jobs:
4447 f.filename === 'CODEOWNERS'
4548 );
4649 if (touchesSensitive) {
47- core.notice('Auto-review disabled: PR modifies .github/prompts, .github/workflows, or CODEOWNERS. Human review required.');
50+ core.notice('Sensitive paths modified ( .github/prompts, .github/workflows, or CODEOWNERS): Claude will review but not auto-approve . Human DEV review required; QA can still be waived if Claude reports QA_REQUIRED: NO .');
4851 }
4952 }
5053
51- core.setOutput('is-auto-review', (isAutoReview && !touchesSensitive).toString());
54+ core.setOutput('is-auto-review', isAutoReview.toString());
55+ core.setOutput('touches-sensitive', touchesSensitive.toString());
5256
5357 - name : Set status to pending
5458 if : steps.check-type.outputs.is-auto-review == 'true'
@@ -121,17 +125,18 @@ jobs:
121125 with :
122126 script : |
123127 const prNumber = ${{ github.event.pull_request.number }};
128+ const owner = context.repo.owner;
129+ const repo = context.repo.repo;
130+ const touchesSensitive = '${{ steps.check-type.outputs.touches-sensitive }}' === 'true';
124131
125132 const comments = await github.rest.issues.listComments({
126- owner: context.repo.owner,
127- repo: context.repo.repo,
128- issue_number: prNumber,
129- per_page: 100
133+ owner, repo, issue_number: prNumber, per_page: 100
130134 });
131135
132- const claudeComment = comments.data
133- .reverse()
134- .find(c => c.user?.login === 'claude[bot]');
136+ const claudeComments = comments.data.filter(c =>
137+ c.user?.login === 'claude[bot]' && c.body?.includes('REVIEW_RESULT:')
138+ );
139+ const claudeComment = claudeComments[claudeComments.length - 1];
135140
136141 const output = claudeComment?.body ?? '';
137142 console.log('Claude comment preview:', output.slice(0, 300));
@@ -143,36 +148,82 @@ jobs:
143148 const hasReviewResult = output.includes('REVIEW_RESULT:');
144149 const passed = !failed && !errored;
145150
146- if (passed && isSimple) {
147- const approvalBody = qaRequired
148- ? 'Auto-approved by Claude — simple fix/chore with no blocking issues. QA approval is still required.'
149- : 'Auto-approved by Claude — simple fix/chore with no blocking issues. No QA needed (non-runtime changes only).';
150-
151- await github.rest.pulls.createReview({
152- owner: context.repo.owner,
153- repo: context.repo.repo,
154- pull_number: prNumber,
155- event: 'APPROVE',
156- body: approvalBody
157- });
158-
159- const labels = ['claude-approved'];
160- if (!qaRequired) {
161- labels.push('no QA needed');
151+ const reviews = await github.rest.pulls.listReviews({
152+ owner, repo, pull_number: prNumber, per_page: 100
153+ });
154+ const ourApprovals = reviews.data.filter(r =>
155+ r.user?.login === 'github-actions[bot]' && r.state === 'APPROVED'
156+ );
157+ const prLabels = (context.payload.pull_request.labels || []).map(l => l.name);
158+ const COMPLEX_MARKER = '🔍 Claude reviewed this PR and found no blocking issues';
159+ const SENSITIVE_MARKER = '🔒 Claude reviewed this PR — sensitive paths modified';
160+
161+ const canAutoApprove = hasReviewResult && passed && isSimple && !touchesSensitive;
162+ const canWaiveQa = hasReviewResult && passed && isSimple && !qaRequired;
163+
164+ if (canAutoApprove) {
165+ if (ourApprovals.length === 0) {
166+ const approvalBody = qaRequired
167+ ? 'Auto-approved by Claude — simple fix/chore with no blocking issues. QA approval is still required.'
168+ : 'Auto-approved by Claude — simple fix/chore with no blocking issues. No QA needed (non-runtime changes only).';
169+ await github.rest.pulls.createReview({
170+ owner, repo, pull_number: prNumber, event: 'APPROVE', body: approvalBody
171+ });
162172 }
163173 await github.rest.issues.addLabels({
164- owner: context.repo.owner,
165- repo: context.repo.repo,
166- issue_number: prNumber,
167- labels
168- });
169- } else if (passed && !isSimple) {
170- await github.rest.issues.createComment({
171- owner: context.repo.owner,
172- repo: context.repo.repo,
173- issue_number: prNumber,
174- body: '🔍 Claude reviewed this PR and found no blocking issues, but assessed it as **complex** — human DEV review is still required before merging.'
174+ owner, repo, issue_number: prNumber, labels: ['claude-approved']
175175 });
176+ } else if (hasReviewResult) {
177+ for (const r of ourApprovals) {
178+ await github.rest.pulls.dismissReview({
179+ owner, repo, pull_number: prNumber, review_id: r.id,
180+ message: 'Dismissed: latest Claude review no longer auto-approves this PR.'
181+ }).catch(e => console.log(`dismissReview: ${e.message}`));
182+ }
183+ if (prLabels.includes('claude-approved')) {
184+ await github.rest.issues.removeLabel({
185+ owner, repo, issue_number: prNumber, name: 'claude-approved'
186+ }).catch(e => console.log(`removeLabel claude-approved: ${e.message}`));
187+ }
188+ if (touchesSensitive && passed && isSimple && !comments.data.some(c =>
189+ c.user?.login === 'github-actions[bot]' && c.body?.startsWith(SENSITIVE_MARKER))) {
190+ const qaNote = qaRequired
191+ ? 'QA approval is still required.'
192+ : 'No QA needed (Claude reported `QA_REQUIRED: NO`).';
193+ await github.rest.issues.createComment({
194+ owner, repo, issue_number: prNumber,
195+ body: `${SENSITIVE_MARKER} (\`.github/prompts\`, \`.github/workflows\`, or \`CODEOWNERS\`). Claude will not auto-approve these PRs — human DEV review is required. ${qaNote}`
196+ });
197+ } else if (!touchesSensitive && passed && !isSimple && !comments.data.some(c =>
198+ c.user?.login === 'github-actions[bot]' && c.body?.startsWith(COMPLEX_MARKER))) {
199+ await github.rest.issues.createComment({
200+ owner, repo, issue_number: prNumber,
201+ body: COMPLEX_MARKER + ', but assessed it as **complex** — human DEV review is still required before merging.'
202+ });
203+ }
204+ }
205+
206+ if (canWaiveQa) {
207+ if (!prLabels.includes('no QA needed')) {
208+ await github.rest.issues.addLabels({
209+ owner, repo, issue_number: prNumber, labels: ['no QA needed']
210+ });
211+ }
212+ } else if (hasReviewResult && prLabels.includes('no QA needed')) {
213+ await github.rest.issues.removeLabel({
214+ owner, repo, issue_number: prNumber, name: 'no QA needed'
215+ }).catch(e => console.log(`removeLabel no QA needed: ${e.message}`));
216+ }
217+
218+ for (const c of claudeComments.slice(0, -1)) {
219+ await github.graphql(
220+ `mutation($id: ID!) {
221+ minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
222+ minimizedComment { isMinimized }
223+ }
224+ }`,
225+ { id: c.node_id }
226+ ).catch(e => console.log(`minimizeComment ${c.id}: ${e.message}`));
176227 }
177228
178229 let description;
@@ -184,6 +235,10 @@ jobs:
184235 description = 'Claude review encountered an error';
185236 } else if (failed) {
186237 description = 'Claude found issues that must be resolved';
238+ } else if (touchesSensitive && isSimple) {
239+ description = canWaiveQa
240+ ? 'No blocking issues — sensitive paths, DEV review required (no QA needed)'
241+ : 'No blocking issues — sensitive paths, DEV review required';
187242 } else if (isSimple) {
188243 description = 'No blocking issues found — PR auto-approved';
189244 } else {
@@ -261,6 +316,9 @@ jobs:
261316 needs : check-member
262317 if : needs.check-member.outputs.is-member == 'true'
263318 runs-on : ubuntu-latest
319+ concurrency :
320+ group : claude-on-demand-review-${{ github.event.issue.number }}
321+ cancel-in-progress : true
264322 permissions :
265323 id-token : write
266324 contents : read
@@ -373,9 +431,10 @@ jobs:
373431 per_page: 100
374432 });
375433
376- const claudeComment = comments.data
377- .reverse()
378- .find(c => c.user?.login === 'claude[bot]');
434+ const claudeComments = comments.data.filter(c =>
435+ c.user?.login === 'claude[bot]' && c.body?.includes('REVIEW_RESULT:')
436+ );
437+ const claudeComment = claudeComments[claudeComments.length - 1];
379438
380439 const output = claudeComment?.body ?? '';
381440 console.log('Claude comment preview:', output.slice(0, 200));
@@ -384,6 +443,17 @@ jobs:
384443 const errored = '${{ steps.claude.outcome }}' === 'failure';
385444 const hasReviewResult = output.includes('REVIEW_RESULT:');
386445
446+ for (const c of claudeComments.slice(0, -1)) {
447+ await github.graphql(
448+ `mutation($id: ID!) {
449+ minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
450+ minimizedComment { isMinimized }
451+ }
452+ }`,
453+ { id: c.node_id }
454+ ).catch(e => console.log(`minimizeComment ${c.id}: ${e.message}`));
455+ }
456+
387457 let description;
388458 if (errored && !output) {
389459 description = 'Claude review failed — credit balance too low or API error';
0 commit comments