88 pull_request :
99 types : [opened, synchronize, ready_for_review]
1010
11+ concurrency :
12+ group : claude-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
13+ cancel-in-progress : true
14+
1115jobs :
1216 auto-review :
1317 if : |
@@ -44,11 +48,12 @@ jobs:
4448 f.filename === 'CODEOWNERS'
4549 );
4650 if (touchesSensitive) {
47- core.notice('Auto-review disabled: PR modifies .github/prompts, .github/workflows, or CODEOWNERS. Human review required.');
51+ 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 .');
4852 }
4953 }
5054
51- core.setOutput('is-auto-review', (isAutoReview && !touchesSensitive).toString());
55+ core.setOutput('is-auto-review', isAutoReview.toString());
56+ core.setOutput('touches-sensitive', touchesSensitive.toString());
5257
5358 - name : Set status to pending
5459 if : steps.check-type.outputs.is-auto-review == 'true'
@@ -121,17 +126,16 @@ jobs:
121126 with :
122127 script : |
123128 const prNumber = ${{ github.event.pull_request.number }};
129+ const owner = context.repo.owner;
130+ const repo = context.repo.repo;
131+ const touchesSensitive = '${{ steps.check-type.outputs.touches-sensitive }}' === 'true';
124132
125133 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
134+ owner, repo, issue_number: prNumber, per_page: 100
130135 });
131136
132- const claudeComment = comments.data
133- .reverse()
134- .find(c => c.user?.login === 'claude[bot]');
137+ const claudeComments = comments.data.filter(c => c.user?.login === 'claude[bot]');
138+ const claudeComment = claudeComments[claudeComments.length - 1];
135139
136140 const output = claudeComment?.body ?? '';
137141 console.log('Claude comment preview:', output.slice(0, 300));
@@ -143,36 +147,77 @@ jobs:
143147 const hasReviewResult = output.includes('REVIEW_RESULT:');
144148 const passed = !failed && !errored;
145149
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');
150+ const reviews = await github.rest.pulls.listReviews({
151+ owner, repo, pull_number: prNumber, per_page: 100
152+ });
153+ const ourApprovals = reviews.data.filter(r =>
154+ r.user?.login === 'github-actions[bot]' && r.state === 'APPROVED'
155+ );
156+ const prLabels = (context.payload.pull_request.labels || []).map(l => l.name);
157+ const COMPLEX_MARKER = '🔍 Claude reviewed this PR and found no blocking issues';
158+ const SENSITIVE_MARKER = '🔒 Claude reviewed this PR — sensitive paths modified';
159+
160+ const canAutoApprove = hasReviewResult && passed && isSimple && !touchesSensitive;
161+ const canWaiveQa = hasReviewResult && passed && isSimple && !qaRequired;
162+
163+ if (canAutoApprove) {
164+ if (ourApprovals.length === 0) {
165+ const approvalBody = qaRequired
166+ ? 'Auto-approved by Claude — simple fix/chore with no blocking issues. QA approval is still required.'
167+ : 'Auto-approved by Claude — simple fix/chore with no blocking issues. No QA needed (non-runtime changes only).';
168+ await github.rest.pulls.createReview({
169+ owner, repo, pull_number: prNumber, event: 'APPROVE', body: approvalBody
170+ });
162171 }
163172 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.'
173+ owner, repo, issue_number: prNumber, labels: ['claude-approved']
175174 });
175+ } else if (hasReviewResult) {
176+ for (const r of ourApprovals) {
177+ await github.rest.pulls.dismissReview({
178+ owner, repo, pull_number: prNumber, review_id: r.id,
179+ message: 'Dismissed: latest Claude review no longer auto-approves this PR.'
180+ }).catch(e => console.log(`dismissReview: ${e.message}`));
181+ }
182+ if (prLabels.includes('claude-approved')) {
183+ await github.rest.issues.removeLabel({
184+ owner, repo, issue_number: prNumber, name: 'claude-approved'
185+ }).catch(e => console.log(`removeLabel claude-approved: ${e.message}`));
186+ }
187+ if (touchesSensitive && passed && isSimple && !comments.data.some(c =>
188+ c.user?.login === 'github-actions[bot]' && c.body?.startsWith(SENSITIVE_MARKER))) {
189+ const qaNote = qaRequired
190+ ? 'QA approval is still required.'
191+ : 'No QA needed (Claude reported `QA_REQUIRED: NO`).';
192+ await github.rest.issues.createComment({
193+ owner, repo, issue_number: prNumber,
194+ body: `${SENSITIVE_MARKER} (\`.github/prompts\`, \`.github/workflows\`, or \`CODEOWNERS\`). Claude will not auto-approve these PRs — human DEV review is required. ${qaNote}`
195+ });
196+ } else if (!touchesSensitive && passed && !isSimple && !comments.data.some(c =>
197+ c.user?.login === 'github-actions[bot]' && c.body?.startsWith(COMPLEX_MARKER))) {
198+ await github.rest.issues.createComment({
199+ owner, repo, issue_number: prNumber,
200+ body: COMPLEX_MARKER + ', but assessed it as **complex** — human DEV review is still required before merging.'
201+ });
202+ }
203+ }
204+
205+ if (canWaiveQa) {
206+ if (!prLabels.includes('no QA needed')) {
207+ await github.rest.issues.addLabels({
208+ owner, repo, issue_number: prNumber, labels: ['no QA needed']
209+ });
210+ }
211+ } else if (hasReviewResult && prLabels.includes('no QA needed')) {
212+ await github.rest.issues.removeLabel({
213+ owner, repo, issue_number: prNumber, name: 'no QA needed'
214+ }).catch(e => console.log(`removeLabel no QA needed: ${e.message}`));
215+ }
216+
217+ for (const c of claudeComments.slice(0, -1)) {
218+ await github.rest.issues.deleteComment({
219+ owner, repo, comment_id: c.id
220+ }).catch(e => console.log(`deleteComment ${c.id}: ${e.message}`));
176221 }
177222
178223 let description;
@@ -184,6 +229,10 @@ jobs:
184229 description = 'Claude review encountered an error';
185230 } else if (failed) {
186231 description = 'Claude found issues that must be resolved';
232+ } else if (touchesSensitive && isSimple) {
233+ description = canWaiveQa
234+ ? 'No blocking issues — sensitive paths, DEV review required (no QA needed)'
235+ : 'No blocking issues — sensitive paths, DEV review required';
187236 } else if (isSimple) {
188237 description = 'No blocking issues found — PR auto-approved';
189238 } else {
@@ -373,9 +422,8 @@ jobs:
373422 per_page: 100
374423 });
375424
376- const claudeComment = comments.data
377- .reverse()
378- .find(c => c.user?.login === 'claude[bot]');
425+ const claudeComments = comments.data.filter(c => c.user?.login === 'claude[bot]');
426+ const claudeComment = claudeComments[claudeComments.length - 1];
379427
380428 const output = claudeComment?.body ?? '';
381429 console.log('Claude comment preview:', output.slice(0, 200));
@@ -384,6 +432,14 @@ jobs:
384432 const errored = '${{ steps.claude.outcome }}' === 'failure';
385433 const hasReviewResult = output.includes('REVIEW_RESULT:');
386434
435+ for (const c of claudeComments.slice(0, -1)) {
436+ await github.rest.issues.deleteComment({
437+ owner: context.repo.owner,
438+ repo: context.repo.repo,
439+ comment_id: c.id
440+ }).catch(e => console.log(`deleteComment ${c.id}: ${e.message}`));
441+ }
442+
387443 let description;
388444 if (errored && !output) {
389445 description = 'Claude review failed — credit balance too low or API error';
0 commit comments