|
|
|
@@ -0,0 +1,263 @@
|
|
|
|
|
name: Auto-Triage Issues
|
|
|
|
|
|
|
|
|
|
on:
|
|
|
|
|
issues:
|
|
|
|
|
types: [opened]
|
|
|
|
|
|
|
|
|
|
jobs:
|
|
|
|
|
triage:
|
|
|
|
|
# Skip if issue mentions @claude (handled by claude.yml)
|
|
|
|
|
if: |
|
|
|
|
|
!contains(github.event.issue.title, '@claude') &&
|
|
|
|
|
!contains(github.event.issue.body || '', '@claude')
|
|
|
|
|
runs-on: ubuntu-latest
|
|
|
|
|
permissions:
|
|
|
|
|
issues: write
|
|
|
|
|
id-token: write
|
|
|
|
|
steps:
|
|
|
|
|
- uses: actions/checkout@v4
|
|
|
|
|
|
|
|
|
|
- name: Fetch repo context for triage
|
|
|
|
|
id: context
|
|
|
|
|
uses: actions/github-script@v7
|
|
|
|
|
with:
|
|
|
|
|
script: |
|
|
|
|
|
// Fetch recent open issues for duplicate detection
|
|
|
|
|
const issues = await github.rest.issues.listForRepo({
|
|
|
|
|
owner: context.repo.owner,
|
|
|
|
|
repo: context.repo.repo,
|
|
|
|
|
state: 'open',
|
|
|
|
|
per_page: 50,
|
|
|
|
|
sort: 'created',
|
|
|
|
|
direction: 'desc',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const issueList = issues.data
|
|
|
|
|
.filter(i => i.number !== context.payload.issue.number && !i.pull_request)
|
|
|
|
|
.map(i => {
|
|
|
|
|
const body = (i.body || '').substring(0, 200).replace(/\n/g, ' ');
|
|
|
|
|
return `#${i.number}: ${i.title} — ${body}`;
|
|
|
|
|
})
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
|
|
|
|
core.setOutput('open_issues', issueList);
|
|
|
|
|
|
|
|
|
|
// Fetch available issue types via GraphQL
|
|
|
|
|
try {
|
|
|
|
|
const result = await github.graphql(`
|
|
|
|
|
{
|
|
|
|
|
repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") {
|
|
|
|
|
issueTypes(first: 20) {
|
|
|
|
|
nodes {
|
|
|
|
|
id
|
|
|
|
|
name
|
|
|
|
|
description
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`, {
|
|
|
|
|
headers: { 'GraphQL-Features': 'issue_types' }
|
|
|
|
|
});
|
|
|
|
|
const types = result.repository.issueTypes.nodes;
|
|
|
|
|
core.setOutput('issue_types', JSON.stringify(types));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log('Could not fetch issue types:', e.message);
|
|
|
|
|
core.setOutput('issue_types', '[]');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
- name: Triage issue with Claude
|
|
|
|
|
id: triage
|
|
|
|
|
uses: anthropics/claude-code-action@v1
|
|
|
|
|
with:
|
|
|
|
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
|
|
|
direct_prompt: |
|
|
|
|
|
You are an issue triager for Twenty, an open-source CRM (https://github.com/twentyhq/twenty).
|
|
|
|
|
You have read-only access to the codebase. Your primary job is to CLASSIFY issues accurately.
|
|
|
|
|
You may also search the code to find relevant context when it helps.
|
|
|
|
|
|
|
|
|
|
## Issue to analyze
|
|
|
|
|
|
|
|
|
|
Issue #${{ github.event.issue.number }}
|
|
|
|
|
Title: ${{ github.event.issue.title }}
|
|
|
|
|
Body:
|
|
|
|
|
${{ github.event.issue.body }}
|
|
|
|
|
|
|
|
|
|
Available issue types in this repo:
|
|
|
|
|
${{ steps.context.outputs.issue_types }}
|
|
|
|
|
|
|
|
|
|
Recent open issues (for duplicate check):
|
|
|
|
|
${{ steps.context.outputs.open_issues }}
|
|
|
|
|
|
|
|
|
|
## Classification
|
|
|
|
|
|
|
|
|
|
Determine the following:
|
|
|
|
|
|
|
|
|
|
**issue_type** — one of:
|
|
|
|
|
- "Bug": something is broken or not working as expected, WITH evidence (error messages, screenshots, clear reproduction steps)
|
|
|
|
|
- "Support": help request, troubleshooting, "how do I...", or a report that LOOKS like a bug but lacks evidence or reproduction steps
|
|
|
|
|
- "Feature": a new capability, enhancement, or product suggestion
|
|
|
|
|
- "Task": refactoring, cleanup, tech debt, documentation, or internal work
|
|
|
|
|
- null: if truly unclear
|
|
|
|
|
|
|
|
|
|
**scope** — one of: "front" (React/UI), "backend" (NestJS/API/DB), "back+front" (both), "infra" (CI/CD/deploy), "self-host" (Docker/self-hosted), or null
|
|
|
|
|
|
|
|
|
|
**good_first_issue** — true only if the fix is clearly straightforward, well-scoped, and good for newcomers
|
|
|
|
|
|
|
|
|
|
**needs_reproduction** — true for bugs that lack clear steps to reproduce
|
|
|
|
|
|
|
|
|
|
**duplicate_of** — an existing issue number ONLY if you are confident this is clearly about the same problem. Do not set for merely related issues.
|
|
|
|
|
|
|
|
|
|
**comment** — a markdown string or null. Default to null. Only set this when you have something genuinely specific and useful to say:
|
|
|
|
|
- You found a likely duplicate (explain which issue and why you think so)
|
|
|
|
|
- A bug is missing reproduction steps (ask for specifics relevant to this issue)
|
|
|
|
|
- A feature is significant enough to suggest moving to a GitHub Discussion
|
|
|
|
|
- You searched the codebase and found a confident, specific code pointer relevant to the issue (file path + brief explanation)
|
|
|
|
|
Do NOT post generic comments. Most issues just need labels, not a bot comment.
|
|
|
|
|
|
|
|
|
|
## How to search the codebase
|
|
|
|
|
|
|
|
|
|
You can use Glob, Grep, and Read to search for relevant code when it helps classification or when you want to provide a specific code pointer in the comment. For example:
|
|
|
|
|
- Grep for error messages or component names mentioned in the issue
|
|
|
|
|
- Search for the feature area being discussed
|
|
|
|
|
Keep searches focused — you have limited turns.
|
|
|
|
|
|
|
|
|
|
## Output
|
|
|
|
|
|
|
|
|
|
After your analysis, output ONLY a JSON object with no markdown fencing:
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
"issue_type": "<Bug|Support|Feature|Task|null>",
|
|
|
|
|
"scope": "<front|backend|back+front|infra|self-host|null>",
|
|
|
|
|
"good_first_issue": <boolean>,
|
|
|
|
|
"needs_reproduction": <boolean>,
|
|
|
|
|
"duplicate_of": <number or null>,
|
|
|
|
|
"comment": "<markdown string or null>"
|
|
|
|
|
}
|
|
|
|
|
claude_args: '--max-turns 5 --model sonnet --allowedTools "Glob,Grep,Read"'
|
|
|
|
|
|
|
|
|
|
- name: Apply triage results
|
|
|
|
|
if: steps.triage.outputs.result
|
|
|
|
|
uses: actions/github-script@v7
|
|
|
|
|
with:
|
|
|
|
|
script: |
|
|
|
|
|
let triage;
|
|
|
|
|
try {
|
|
|
|
|
const raw = `${{ steps.triage.outputs.result }}`;
|
|
|
|
|
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
|
|
|
if (!jsonMatch) {
|
|
|
|
|
console.log('No JSON found in Claude response:', raw);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
triage = JSON.parse(jsonMatch[0]);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log('Failed to parse triage response:', e.message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const issueNumber = context.payload.issue.number;
|
|
|
|
|
const owner = context.repo.owner;
|
|
|
|
|
const repo = context.repo.repo;
|
|
|
|
|
|
|
|
|
|
// 1. Set issue type via GraphQL
|
|
|
|
|
if (triage.issue_type) {
|
|
|
|
|
try {
|
|
|
|
|
const issueTypes = JSON.parse(`${{ steps.context.outputs.issue_types }}`);
|
|
|
|
|
// Map "Support" to the "Troubleshooting" GitHub issue type
|
|
|
|
|
const typeName = triage.issue_type === 'Support' ? 'Troubleshooting' : triage.issue_type;
|
|
|
|
|
const matchedType = issueTypes.find(t => t.name === typeName);
|
|
|
|
|
if (matchedType) {
|
|
|
|
|
const issueData = await github.graphql(`
|
|
|
|
|
{
|
|
|
|
|
repository(owner: "${owner}", name: "${repo}") {
|
|
|
|
|
issue(number: ${issueNumber}) {
|
|
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`);
|
|
|
|
|
const issueNodeId = issueData.repository.issue.id;
|
|
|
|
|
|
|
|
|
|
await github.graphql(`
|
|
|
|
|
mutation {
|
|
|
|
|
updateIssue(input: {
|
|
|
|
|
id: "${issueNodeId}",
|
|
|
|
|
issueTypeId: "${matchedType.id}"
|
|
|
|
|
}) {
|
|
|
|
|
issue { number }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`, {
|
|
|
|
|
headers: { 'GraphQL-Features': 'issue_types' }
|
|
|
|
|
});
|
|
|
|
|
console.log(`Set issue type to: ${typeName}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log(`Could not set issue type: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Build label list
|
|
|
|
|
const labels = [];
|
|
|
|
|
|
|
|
|
|
const validScopes = ['front', 'backend', 'back+front', 'infra', 'self-host'];
|
|
|
|
|
if (triage.scope && validScopes.includes(triage.scope)) {
|
|
|
|
|
labels.push(triage.scope === 'self-host' ? 'scope: self-host' : `scope: ${triage.scope}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (triage.good_first_issue === true) {
|
|
|
|
|
labels.push('good first issue');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (triage.needs_reproduction === true) {
|
|
|
|
|
labels.push('needs: reproduction');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Ensure labels exist and apply them
|
|
|
|
|
if (labels.length > 0) {
|
|
|
|
|
const existingLabels = await github.rest.issues.listLabelsForRepo({
|
|
|
|
|
owner, repo, per_page: 100,
|
|
|
|
|
});
|
|
|
|
|
const existingLabelNames = new Set(existingLabels.data.map(l => l.name));
|
|
|
|
|
|
|
|
|
|
const labelColors = {
|
|
|
|
|
'needs: reproduction': 'fbca04',
|
|
|
|
|
'scope: self-host': 'bfd4f2',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const label of labels) {
|
|
|
|
|
if (!existingLabelNames.has(label)) {
|
|
|
|
|
try {
|
|
|
|
|
await github.rest.issues.createLabel({
|
|
|
|
|
owner, repo,
|
|
|
|
|
name: label,
|
|
|
|
|
color: labelColors[label] || 'ededed',
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log(`Could not create label "${label}": ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await github.rest.issues.addLabels({
|
|
|
|
|
owner, repo,
|
|
|
|
|
issue_number: issueNumber,
|
|
|
|
|
labels,
|
|
|
|
|
});
|
|
|
|
|
console.log(`Applied labels: ${labels.join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Post comment only if Claude had something specific to say
|
|
|
|
|
if (triage.comment) {
|
|
|
|
|
const commentParts = [triage.comment];
|
|
|
|
|
|
|
|
|
|
if (triage.duplicate_of && Number.isInteger(triage.duplicate_of)) {
|
|
|
|
|
commentParts.push(`\n> **Possible duplicate** of #${triage.duplicate_of} — please check if this is the same issue.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await github.rest.issues.createComment({
|
|
|
|
|
owner, repo,
|
|
|
|
|
issue_number: issueNumber,
|
|
|
|
|
body: commentParts.join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|