claude-comment-gate.ymlβ’11.2 kB
name: Claude Comment Gate (no-checkout)
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
concurrency:
group: claude-comment-gate-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
issues: write
actions: write
jobs:
gate:
# Only when someone actually tags @claude on a PR thread/comment/review
if: |
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@claude')
) || (
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude')
) || (
github.event_name == 'pull_request_review' &&
github.event.review.body &&
contains(github.event.review.body, '@claude')
)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
sparse-checkout: |
scripts/workflows/claude/
sparse-checkout-cone-mode: false
- name: Detect review command
id: classify
run: |
node scripts/workflows/claude/is-review-command.mjs > classify.json
cat classify.json
is_review=$(jq -r '.isReview // false' classify.json)
mode=$(jq -r '.mode // ""' classify.json)
rerun=$(jq -r '.rerun // false' classify.json)
echo "isReview=$is_review" >> "$GITHUB_OUTPUT"
echo "mode=$mode" >> "$GITHUB_OUTPUT"
echo "rerun=$rerun" >> "$GITHUB_OUTPUT"
env:
CLAUDE_TRIGGER_PHRASE: '@claude'
- name: Exit if not review
if: steps.classify.outputs.isReview != 'true'
run: |
echo 'Not a review trigger; leaving for command workflow.'
- name: Validate CLAUDE_GATE_TOKEN
if: steps.classify.outputs.isReview == 'true'
run: |
if [ -z "${{ secrets.CLAUDE_GATE_TOKEN }}" ]; then
echo "::error::CLAUDE_GATE_TOKEN not set (repo β Settings β Secrets and variables β Actions)"
exit 1
fi
- name: Who am I (should be your PAT user)
if: steps.classify.outputs.isReview == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.CLAUDE_GATE_TOKEN }}
script: |
const me = await github.rest.users.getAuthenticated();
core.info(`Using token for @${me.data.login} (type=${me.data.type})`);
if (me.data.type === 'Bot') {
core.setFailed(`β Token is for bot account: ${me.data.login}`);
}
- name: Extract PR number + intent
if: steps.classify.outputs.isReview == 'true'
id: info
uses: actions/github-script@v7
env:
CLAUDE_MODE_HINT: ${{ steps.classify.outputs.mode }}
CLAUDE_RERUN_HINT: ${{ steps.classify.outputs.rerun }}
with:
script: |
const prNumber = (context.payload.pull_request?.number) || context.issue.number;
if (!prNumber) { core.setFailed('No PR number found'); return; }
const raw = (context.payload.comment?.body || context.payload.review?.body || '').toString();
const lc = raw.toLowerCase();
let modeHint = (process.env.CLAUDE_MODE_HINT || '').toLowerCase();
let rerunHint = (process.env.CLAUDE_RERUN_HINT || '').toLowerCase() === 'true';
const tokens = (lc.match(/@claude\s*:?\s*([^\n]*)/)?.[1] || '')
.trim()
.split(/\s+/)
.filter(Boolean);
let mode = modeHint === 'ultra' ? 'ultra' : modeHint === 'review' ? 'review' : null;
let rerun = rerunHint;
for (const token of tokens) {
if (!mode && ['ultra', 'review'].includes(token)) mode = token;
if (['re-review', 'rereview', 'rerun', 're-run', 'again', 'refresh', 'recheck'].includes(token)) rerun = true;
}
if (!mode) mode = 'review';
core.setOutput('pr', String(prNumber));
core.setOutput('mode', mode === 'ultra' ? 'ultra' : 'review');
core.setOutput('label', mode === 'ultra' ? 'claude:ultra' : 'claude:review');
core.setOutput('rerun', String(rerun));
- name: Guard - only maintainers may trigger Claude reviews
if: steps.classify.outputs.isReview == 'true'
id: guard
uses: actions/github-script@v7
env:
CLAUDE_ALLOWED_USERS: kesslerio
with:
github-token: ${{ secrets.CLAUDE_GATE_TOKEN }}
script: |
const user = context.actor;
const assoc =
context.payload.comment?.author_association ||
context.payload.review?.author_association || 'NONE';
const maintainer = ['OWNER','MEMBER','COLLABORATOR'].includes(assoc);
// Optional allowlist (comma-separated), e.g. "kesslerio,othermaintainer"
const allow = (process.env.CLAUDE_ALLOWED_USERS || '').split(',').map(s=>s.trim()).filter(Boolean);
const allowHit = allow.length === 0 || allow.includes(user);
if (!maintainer && !allowHit) {
core.notice(`@claude trigger ignored for ${user} (assoc=${assoc}). Only maintainers may trigger reviews.`);
core.setOutput('blocked', 'true');
} else {
core.setOutput('blocked', 'false');
}
- name: Ensure label exists
if: steps.classify.outputs.isReview == 'true' && steps.guard.outputs.blocked != 'true'
uses: actions/github-script@v7
with:
# Use validated PAT to trigger downstream workflows
github-token: ${{ secrets.CLAUDE_GATE_TOKEN }}
script: |
const name = '${{ steps.info.outputs.label }}';
const palette = {
'claude:ultra': { color: 'a371f7', description: 'Run deep Opus review' },
'claude:review': { color: '0e8a16', description: 'Run Sonnet review' },
};
const def = palette[name] || { color: '0366d6', description: 'Claude PR review trigger' };
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({
owner: context.repo.owner, repo: context.repo.repo,
name, color: def.color, description: def.description
});
} else { throw e; }
}
- name: Add or toggle label (handles re-review)
if: steps.classify.outputs.isReview == 'true' && steps.guard.outputs.blocked != 'true'
id: relabel
uses: actions/github-script@v7
with:
# Use validated PAT to trigger downstream workflows
github-token: ${{ secrets.CLAUDE_GATE_TOKEN }}
script: |
const issue_number = Number('${{ steps.info.outputs.pr }}');
const requested = '${{ steps.info.outputs.label }}';
const rerun = '${{ steps.info.outputs.rerun }}' === 'true';
// Current labels
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner, repo: context.repo.repo, issue_number
});
const names = new Set(labels.map(l => l.name));
const hasReview = names.has('claude:review');
const hasUltra = names.has('claude:ultra');
const hasRequested = names.has(requested);
async function remove(name) {
try {
await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number, name });
} catch (e) { if (e.status !== 404) throw e; }
}
async function add() {
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number, labels: [requested] });
}
if (rerun) {
if (hasReview) await remove('claude:review');
if (hasUltra) await remove('claude:ultra');
await add();
core.setOutput('action', 're-run');
return;
}
if (!hasRequested) {
if (requested === 'claude:ultra' && hasReview) await remove('claude:review');
if (requested === 'claude:review' && hasUltra) await remove('claude:ultra');
await add();
core.setOutput('action', 'queued');
return;
}
core.setOutput('action', 'already-queued');
- name: Acknowledge
if: steps.classify.outputs.isReview == 'true' && steps.guard.outputs.blocked != 'true'
uses: actions/github-script@v7
with:
# Use validated PAT to trigger downstream workflows
github-token: ${{ secrets.CLAUDE_GATE_TOKEN }}
script: |
const issue_number = Number('${{ steps.info.outputs.pr }}');
const mode = '${{ steps.info.outputs.mode }}';
const action = '${{ steps.relabel.outputs.action }}';
const tag = mode === 'ultra' ? '**Opus (ultra)**' : '**Sonnet**';
const body =
action === 're-run' ? `π Re-queued ${tag} review in PR-safe context. I'll post results here shortly.` :
action === 'queued' ? `π’ Queued ${tag} review in PR-safe context. I'll post results here shortly.` :
`βΉοΈ ${tag} review is already queued. Push new commits or comment \`@claude re-review\` to force a re-run.`;
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number, body });
- name: Kick off review (dispatch)
if: steps.classify.outputs.isReview == 'true' && steps.guard.outputs.blocked != 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.CLAUDE_GATE_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// Resolve default branch dynamically
const { data: r } = await github.rest.repos.get({ owner, repo });
const ref = r.default_branch || 'main';
// Dispatch the workflow on the default branch
await github.rest.actions.createWorkflowDispatch({
owner, repo,
workflow_id: 'claude-pr-review-labeled.yml',
ref,
inputs: {
pr: String('${{ steps.info.outputs.pr }}'),
label: '${{ steps.info.outputs.label }}',
source: 'gate'
}
});
core.info(`Dispatched review workflow for PR #${{ steps.info.outputs.pr }} on ref "${ref}" with label ${{ steps.info.outputs.label }}`);