claude-commands.ymlβ’21.4 kB
name: Claude Commands
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, edited, assigned]
concurrency:
group: claude-commands-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
issues: write
actions: read
jobs:
handle:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude')
) || (
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude')
) || (
github.event_name == 'issues' && (
contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')
)
)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
sparse-checkout: |
scripts/workflows/claude/
sparse-checkout-cone-mode: false
- name: Classify command
id: classify
run: |
node scripts/workflows/claude/parse-command.mjs > classify.json
cat classify.json
should_run=$(jq -r '.shouldRun // false' classify.json)
command_type=$(jq -r '.command.type // ""' classify.json)
is_maintainer=$(jq -r '.isMaintainer // false' classify.json)
issue=$(jq -r '.issue // ""' classify.json)
is_pr=$(jq -r '.isPR // false' classify.json)
actor=$(jq -r '.actor // ""' classify.json)
association=$(jq -r '.association // ""' classify.json)
echo "should_run=$should_run" >> "$GITHUB_OUTPUT"
echo "command_type=$command_type" >> "$GITHUB_OUTPUT"
echo "is_maintainer=$is_maintainer" >> "$GITHUB_OUTPUT"
echo "issue=$issue" >> "$GITHUB_OUTPUT"
echo "is_pr=$is_pr" >> "$GITHUB_OUTPUT"
echo "actor=$actor" >> "$GITHUB_OUTPUT"
echo "association=$association" >> "$GITHUB_OUTPUT"
env:
CLAUDE_TRIGGER_PHRASE: '@claude'
- name: Skip non-command events
if: steps.classify.outputs.should_run != 'true'
run: echo 'No @claude command detected.'
- name: Skip review commands
if: steps.classify.outputs.should_run == 'true' && steps.classify.outputs.command_type == 'review'
run: echo 'Review command detected; handled by review workflow.'
- name: Enforce maintainer access
if: steps.classify.outputs.should_run == 'true' && steps.classify.outputs.command_type != 'review'
run: |
if [ "${{ steps.classify.outputs.is_maintainer }}" != "true" ]; then
echo '::notice::Only maintainers may trigger Claude automation.'
exit 1
fi
- name: Full checkout for command execution
if: steps.classify.outputs.should_run == 'true' && steps.classify.outputs.is_maintainer == 'true' && steps.classify.outputs.command_type != 'review'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch issue context
if: steps.classify.outputs.should_run == 'true' && steps.classify.outputs.is_maintainer == 'true' && steps.classify.outputs.command_type != 'review'
id: context
uses: actions/github-script@v7
with:
script: |
const issueNumber = '${{ steps.classify.outputs.issue }}';
if (!issueNumber) {
core.setOutput('issue_title', '');
core.setOutput('issue_body', '');
core.setOutput('comment_body', '');
core.setOutput('recent_comments', '');
core.setOutput('previous_analysis', '');
return;
}
// Fetch issue details
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
// Get the triggering comment
const commentId = context.payload.comment?.id;
let commentBody = '';
if (commentId) {
const { data: comment } = await github.rest.issues.getComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId
});
commentBody = comment.body || '';
}
// Fetch all comments for context
const { data: allComments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100
});
// Find most recent Claude response (for follow-up context)
const claudeResponses = allComments
.filter(c => c.body.includes('<!-- claude-command-response -->'))
.reverse(); // Most recent first
const previousAnalysis = claudeResponses.length > 0
? claudeResponses[0].body.replace('<!-- claude-command-response -->\n', '').slice(0, 8000)
: '';
// Get recent user comments (last 5, excluding triggering comment and bot comments)
const recentComments = allComments
.filter(c => c.id !== commentId) // Exclude triggering comment
.filter(c => c.user.type !== 'Bot') // Exclude bot comments
.filter(c => !c.body.includes('<!-- claude-command-response -->')) // Exclude previous Claude responses
.slice(-5) // Get last 5 user comments
.map(c => ({
author: c.user.login,
created_at: c.created_at,
body: c.body.slice(0, 2000) // Limit each comment to 2000 chars
}));
const recentCommentsFormatted = recentComments.length > 0
? recentComments.map(c =>
`**@${c.author}** (${new Date(c.created_at).toISOString().split('T')[0]}):\n${c.body}\n`
).join('\n---\n\n')
: '';
// Parse issue body for related issues (e.g., #42, #100)
const issueRefPattern = /#(\d+)/g;
const referencedIssueNumbers = [...(issue.body || '').matchAll(issueRefPattern)]
.map(match => parseInt(match[1]))
.filter(num => num !== parseInt(issueNumber)) // Exclude self-reference
.slice(0, 3); // Limit to first 3 related issues
// Fetch related issues (title, state, first 500 chars of body)
const relatedIssues = [];
for (const relatedNum of referencedIssueNumbers) {
try {
const { data: relatedIssue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: relatedNum
});
relatedIssues.push({
number: relatedNum,
title: relatedIssue.title,
state: relatedIssue.state,
body: (relatedIssue.body || '').slice(0, 500) // Limit to 500 chars
});
} catch (error) {
core.warning(`Failed to fetch related issue #${relatedNum}: ${error.message}`);
}
}
const relatedIssuesFormatted = relatedIssues.length > 0
? relatedIssues.map(ri =>
`**#${ri.number}** (${ri.state}): ${ri.title}\n${ri.body}${ri.body.length >= 500 ? '...' : ''}`
).join('\n\n---\n\n')
: '';
core.setOutput('issue_title', issue.title || '');
core.setOutput('issue_body', issue.body || '');
core.setOutput('comment_body', commentBody);
core.setOutput('recent_comments', recentCommentsFormatted);
core.setOutput('previous_analysis', previousAnalysis);
core.setOutput('related_issues', relatedIssuesFormatted);
core.info(`Fetched context for issue #${issueNumber}: ${issue.title}`);
core.info(`Included ${recentComments.length} recent comments for context`);
if (previousAnalysis) {
core.info(`Found previous Claude analysis (${previousAnalysis.length} chars) - this is a follow-up`);
}
if (relatedIssues.length > 0) {
core.info(`Found ${relatedIssues.length} related issue(s): ${relatedIssues.map(ri => `#${ri.number}`).join(', ')}`);
}
- name: Claude Command Handler
if: steps.classify.outputs.should_run == 'true' && steps.classify.outputs.is_maintainer == 'true' && steps.classify.outputs.command_type != 'review'
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ github.token }}
track_progress: false
claude_args: >-
--model claude-sonnet-4-5
--max-turns 32
--allowed-tools "Read,Edit,Write,Bash(git status),Bash(git add:*),Bash(git commit:*),Bash(git push:*),Bash(git checkout:*),Bash(git switch:*),Bash(git diff:*),Bash(gh pr create:*),Bash(gh pr comment:*),Bash(gh pr view:*),Bash(npm run *),Bash(npm test),Bash(npm run test:*),Bash(npm run build),Bash(npm run lint:*),Bash(node *),Bash(npx *),Bash(rg *),Bash(fd *)"
--output-format stream-json
prompt: |
You are Claude acting via GitHub Actions for the Attio MCP repo.
π¨ **CRITICAL EXECUTION CONSTRAINTS** π¨
- You have 32 turns MAX before workflow terminates
- **BY TURN 20**: STOP using tools and start writing your final response
- **BY TURN 25**: You MUST have completed your full markdown report
- If you hit turn 32 without outputting a complete response, it will be lost
- Your FINAL message must contain the complete analysis/report in markdown format
- Do NOT end with tool calls - the last message MUST be pure markdown text
## Context
- **Issue**: #${{ steps.classify.outputs.issue }}
- **Issue Title**: ${{ steps.context.outputs.issue_title }}
- **Triggering Command**: "${{ steps.context.outputs.comment_body }}"
**Full Issue Description**:
${{ steps.context.outputs.issue_body }}
**Recent Discussion** (last 5 user comments for context):
${{ steps.context.outputs.recent_comments || 'No recent comments' }}
**Related Issues** (referenced in issue body):
${{ steps.context.outputs.related_issues || 'No related issues referenced' }}
**Previous Claude Analysis** (if this is a follow-up):
${{ steps.context.outputs.previous_analysis || 'No previous analysis - this is the first request on this issue' }}
**Follow-Up Instructions**:
- If "Previous Claude Analysis" exists, this is a FOLLOW-UP request
- Build on your previous analysis, don't repeat it
- Reference your previous recommendations when relevant
- Acknowledge if user comments since your last analysis provide new context
- If user asks a clarifying question, answer directly without repeating full framework
- **Note**: Issue body may have been edited since your previous analysis - if you notice discrepancies, acknowledge the updates and adjust recommendations accordingly
## Task Routing
First, examine the triggering command to determine your mode:
- **ANALYSIS MODE** (vague/exploratory requests like "thoughts?", "help", "analyze this"):
Provide comprehensive issue analysis using the framework below
- **EXECUTION MODE** (specific instructions like "implement X", "fix Y", "refactor Z"):
Follow the maintainer's instructions precisely using repo standards
## Exploration Philosophy: "Good Enough" > "Exhaustive"
**CRITICAL MINDSET SHIFT**: Synthesis is more valuable than exhaustive exploration.
**Analysis Mode Guidelines:**
- Read 1-3 KEY files (issue description, CLAUDE.md, one example file)
- You don't need to read the entire codebase
- Representative sampling beats exhaustive analysis
- After 8-10 tool calls, STOP and synthesize what you've learned
- Write sections progressively as you learn (don't wait until the end)
**Execution Mode Guidelines:**
- Focus on directly relevant files only (not the entire project)
- 12-15 tool calls for implementation + testing should be sufficient
- If you need more context, make educated assumptions and document them
- Leave time for quality gates and final summary
**Remember**: A well-reasoned analysis from limited context is better than hitting turn limits with no output.
## ANALYSIS MODE Framework
Provide analysis structured in these 5 sections:
**1. Issue Quality Assessment**
- Problem definition clarity and underlying goals
- Requirements completeness and acceptance criteria
- Scope boundaries and potential scope creep risks
**2. Anti-Pattern Risk Detection**
- Infrastructure-without-validation, symptom-driven development, complexity escalation, premature optimization
- Explain WHY risky + suggest healthier alternatives
**3. Implementation Strategy**
- Concrete technical approach (technology/library choices with rationale)
- File structure, testing strategy, migration path, integration points
**4. Educational Recommendations**
- Relevant docs (official docs, CLAUDE.md sections)
- Similar solved problems in codebase
- Common pitfalls and validation techniques
**5. Actionable Next Steps**
- Numbered, concrete actions with rationale and dependencies
Keep analysis concise but insightful. Use a coaching tone explaining the "why" behind recommendations.
## EXECUTION MODE Guidelines
Follow repo standards from `CLAUDE.md`.
**Implementation Standards:**
- Conventional commit format: `Type: Subject #issue-number`
- Single Responsibility Principle (SRP)
- Focused, atomic changes
**Quality Gates:**
- Run appropriate tests:
- `npm run test:offline` for unit tests (fast, no API calls)
- `npm run test:integration` for API integration tests (requires ATTIO_API_KEY)
- `npm run test:e2e` for end-to-end workflows
- Run lint/format: `npm run fix:all`
- Verify TypeScript: `npx tsc --noEmit`
**Workflow:**
1. Read relevant code + CLAUDE.md first
2. Implement changes following standards
3. Run quality gates
4. Commit with conventional format linking issue
5. Report results (branch/PR URL, tests, any issues)
**Constraints:**
- Never expose secrets or tokens
- Ask for clarification if ambiguous
- Flag anti-pattern risks proactively
## Reply Format
**Analysis Mode**: Use 5-section structure above. Be concise, coaching tone, reference files (file:line), end with actionable steps.
**Execution Mode**: Confirm task β implement β run quality gates β report results (branch/PR URL, tests, issues).
**Always**: Be concise but thorough, reference code locations, flag anti-pattern risks.
---
π¨ **FINAL REMINDER: OUTPUT YOUR COMPLETE RESPONSE** π¨
- Your LAST message MUST contain the full markdown report/analysis
- Do NOT end with tool calls - the workflow will capture ONLY your final text message
- Remember: 32 turn limit, stop tools by turn 20, finish report by turn 25
- If you hit the turn limit without a complete response, the comment will be incomplete/lost
- Synthesis > exhaustive exploration - write progressively, don't wait until the end
- name: Capture Claude response
if: always() && steps.claude.outcome != 'skipped' && steps.claude.outputs.execution_file != ''
id: capture
uses: actions/github-script@v7
env:
EXEC_FILE: ${{ steps.claude.outputs.execution_file }}
with:
script: |
const fs = require('fs');
const { extractAllTextFromSession, dedupeAdjacent } = require('./scripts/workflows/claude/extract-text.js');
const execFile = process.env.EXEC_FILE;
if (!execFile || !fs.existsSync(execFile)) {
core.info('No execution file found');
return;
}
const raw = fs.readFileSync(execFile, 'utf8').replace(/^\uFEFF/, '');
const chunks = extractAllTextFromSession(raw);
const response = dedupeAdjacent(chunks).join('\n').trim();
if (!response) {
core.warning('No response content found');
core.warning(`First 500 chars of execution file:\n${raw.slice(0, 500)}`);
core.exportVariable('CLAUDE_RESPONSE_EMPTY', 'true');
return;
}
core.exportVariable('CLAUDE_RESPONSE', response);
core.info(`Captured response (${response.length} chars)`);
- name: Post response to issue
if: always() && steps.capture.outcome == 'success' && env.CLAUDE_RESPONSE != ''
uses: actions/github-script@v7
with:
script: |
const issueNumber = Number('${{ steps.classify.outputs.issue }}');
const response = process.env.CLAUDE_RESPONSE;
if (!issueNumber || !response) {
core.info('No issue number or response to post');
return;
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function withRetries(fn, label, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === attempts) throw error;
const delay = 1000 * attempt;
core.warning(`Attempt ${attempt} failed. Retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
const marker = `<!-- claude-command-response -->`;
const body = `${marker}\n${response}`;
// Post the comment with retries
await withRetries(
() => github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: body
}),
'create comment'
);
core.info(`Posted response to issue #${issueNumber}`);
- name: Post diagnostic comment for empty response
if: always() && steps.claude.outcome == 'success' && env.CLAUDE_RESPONSE_EMPTY == 'true'
uses: actions/github-script@v7
with:
script: |
const issueNumber = Number('${{ steps.classify.outputs.issue }}');
if (!issueNumber) return;
const runUrl = `https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
const triggerCommand = `${{ steps.context.outputs.comment_body }}`;
const lines = [
'<!-- claude-command-diagnostic -->',
'β οΈ **Claude Command Handler Issue**',
'',
'The workflow completed successfully but no response was captured from Claude. This usually means:',
'- Claude used all available turns on tool calls without generating a final text response',
'- The output format from claude-code-action was unexpected',
'',
'**Debug Information:**',
`- Workflow Run: ${runUrl}`,
`- Issue: #${issueNumber}`,
`- Triggering Command: "${triggerCommand}"`,
'',
'Please review the workflow logs for details. The response capture logic has been enhanced to handle multiple output formats, but further investigation may be needed.'
];
const body = lines.join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: body
});
core.info(`Posted diagnostic comment to issue #${issueNumber}`);
- name: Summarize skip
if: steps.classify.outputs.should_run != 'true'
run: echo 'Workflow exited without invoking Claude.'