Files
2026-07-13 12:38:34 +08:00

471 lines
20 KiB
YAML

name: Issue Triage
on:
issues:
types: [opened, reopened, edited]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to triage retrospectively"
required: true
type: string
permissions:
contents: read
issues: write
id-token: write
jobs:
classify:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Load issue context for dispatch runs
if: github.event_name == 'workflow_dispatch'
id: issue-context
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ISSUE_NUMBER: ${{ inputs.issue_number }}
with:
script: |
const issue_number = Number(process.env.ISSUE_NUMBER);
if (!Number.isInteger(issue_number) || issue_number <= 0) {
core.setFailed(`Invalid issue_number: ${process.env.ISSUE_NUMBER}`);
return;
}
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
});
if (issue.pull_request) {
core.setFailed(`Issue #${issue_number} is a pull request, not an issue.`);
return;
}
core.setOutput("issue_number", String(issue.number));
core.setOutput("issue_title", issue.title || "");
core.setOutput("issue_body", issue.body || "");
core.setOutput("issue_state", issue.state || "open");
- name: Write triage schema
run: |
cat > /tmp/issue-triage-schema.json <<'EOF'
{
"type": "object",
"additionalProperties": false,
"properties": {
"category": {
"type": "string",
"enum": ["docs", "py", "ts", "cli", "tool-request", "feature-request", "support", "other"]
},
"is_bug": {
"type": "boolean"
},
"reason": {
"type": "string"
},
"should_comment": {
"type": "boolean"
},
"comment_body": {
"type": "string"
}
},
"required": ["category", "is_bug", "reason", "should_comment", "comment_body"]
}
EOF
- name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code
- name: Classify issue with Claude Sonnet
id: classify
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ISSUE_TITLE: ${{ github.event_name == 'workflow_dispatch' && steps.issue-context.outputs.issue_title || github.event.issue.title }}
ISSUE_BODY: ${{ github.event_name == 'workflow_dispatch' && steps.issue-context.outputs.issue_body || github.event.issue.body }}
run: |
set -uo pipefail
which claude
claude --version || true
PROMPT=$(cat <<'EOF'
Read the issue title and body from the ISSUE_TITLE and ISSUE_BODY environment variables.
Classify the issue for routing and determine whether it is a bug report.
Allowed categories:
- docs
- py
- ts
- cli
- tool-request
- feature-request
- support
- other
Use semantic meaning, not only keywords.
Choose tool-request when the issue is primarily asking for a new integration, toolkit, app, or tool.
Choose feature-request when the issue is asking for a product, SDK, docs, or CLI improvement that is not a tool request.
Choose support when the issue is primarily a product/support question, account/integration configuration problem, or a hosted Composio platform/tooling problem rather than a code defect in this repository. This includes managed OAuth app configuration, provider app verification or consent-screen blocks, requested scopes for hosted/managed integrations, dashboard/back-office setup, billing/account questions, usage questions where the SDK appears to be behaving as designed, and bugs in existing hosted integrations/tools/toolkits that require backend/tool-catalog/support follow-up rather than SDK code changes.
For example, an issue about Google Drive managed OAuth scopes or Google blocking the managed app should be support, not ts and not a bug, unless the issue clearly identifies a defect in SDK code.
For example, an issue like GitHub #3291 where an existing hosted tool such as SPOTIFY_GET_PLAYLIST_ITEMS calls a deprecated third-party API endpoint, fails with 403, or needs a backend/tool definition update should be support and a bug, not ts, not py, not cli, and not tool-request.
Choose cli only when the issue is specifically about the CLI experience or ts/packages/cli.
Choose py only when the issue is primarily about Python SDK code in this repository.
Choose ts only when the issue is primarily about TypeScript SDK code or TS packages other than the CLI in this repository.
Choose docs only when the issue is primarily about documentation.
Never choose tool-request for a broken existing integration or tool. Those are bugs, not tool requests, even if the title/body mentions a tool name or toolkit slug. If the broken existing integration/tool does not identify an SDK, CLI, docs, or Python code defect in this repository, choose support so it is forwarded to Plain.
Set is_bug to true when the issue is primarily reporting broken behavior, a defect, an error, a regression, or unexpected behavior, including support-routed hosted integration/tool bugs.
Set is_bug to false for feature requests, questions, maintenance, cleanup, and true new-tool requests.
Set should_comment to true when the issue appears incorrect, non-actionable, missing the information needed to reproduce or investigate, or when category is support.
Set should_comment to false otherwise.
If should_comment is true, provide comment_body as a short issue comment addressed to the reporter.
The comment must:
- clearly say it is an automated Claude triage note
- clearly say Claude cannot reply back in a conversation
- ask for the specific missing details needed to investigate, especially repro steps, expected behavior, actual behavior, environment, logs, or screenshots when relevant
- for support issues, say this does not look like an SDK bug and that it is being routed to support
- stay concise and professional
If should_comment is false, set comment_body to an empty string.
EOF
)
set +e
claude -p "$PROMPT" \
--model claude-sonnet-4-6 \
--max-turns 10 \
--output-format json \
--dangerously-skip-permissions \
--append-system-prompt "Respond with ONLY a raw JSON object (no markdown fences, no prose) that matches the schema in /tmp/issue-triage-schema.json." \
> /tmp/claude-response.json 2> /tmp/claude-stderr.log
CLAUDE_EXIT=$?
set -e
echo "--- claude exit code: $CLAUDE_EXIT ---"
echo "--- claude stderr ---"
cat /tmp/claude-stderr.log || true
echo "--- end stderr ---"
echo "--- raw claude response ---"
cat /tmp/claude-response.json || true
echo
echo "--- end raw ---"
if [ "$CLAUDE_EXIT" -ne 0 ]; then
echo "Claude CLI failed with exit code $CLAUDE_EXIT"
exit 1
fi
STRUCTURED=$(jq -r '.result' /tmp/claude-response.json)
# Strip optional ```json fences just in case
STRUCTURED=$(echo "$STRUCTURED" | sed -E 's/^```(json)?//; s/```$//' | jq -c .)
echo "--- parsed structured output ---"
echo "$STRUCTURED"
echo "--- end parsed ---"
{
echo "structured_output<<CLAUDE_EOF"
echo "$STRUCTURED"
echo "CLAUDE_EOF"
} >> "$GITHUB_OUTPUT"
- name: Apply labels, assignees, and tool-request handling
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
STRUCTURED_OUTPUT: ${{ steps.classify.outputs.structured_output }}
PLAIN_API_KEY: ${{ secrets.PLAIN_API_KEY }}
with:
script: |
const text = process.env.STRUCTURED_OUTPUT || "";
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
core.setFailed(`Could not parse structured_output as JSON: ${text}`);
return;
}
const allowed = new Set(["docs", "py", "ts", "cli", "tool-request", "feature-request", "support", "other"]);
if (!allowed.has(parsed.category)) {
core.setFailed(`Unexpected category: ${parsed.category}`);
return;
}
const category = parsed.category;
const isBug = Boolean(parsed.is_bug);
const reason = parsed.reason || "";
const shouldComment = Boolean(parsed.should_comment);
const commentBody = parsed.comment_body || "";
const issue_number = context.eventName === "workflow_dispatch"
? Number("${{ steps.issue-context.outputs.issue_number }}")
: context.payload.issue.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const issueState = context.eventName === "workflow_dispatch"
? "${{ steps.issue-context.outputs.issue_state }}"
: context.payload.issue.state;
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number,
});
const labels = [];
const assignees = [];
const suppressToolRequestHandling = isBug && category === "tool-request";
const isSupport = category === "support";
if (suppressToolRequestHandling) {
core.info(
`Suppressing tool-request handling for issue #${issue_number} because it is classified as a bug. Original reason: ${reason}`
);
}
if (isBug && !isSupport) {
labels.push("bug");
}
if (category === "docs") {
labels.push("docs");
assignees.push("shawnesquivel", "Sushmithamallesh");
} else if (category === "py") {
labels.push("py");
assignees.push("jkomyno");
} else if (category === "ts") {
labels.push("ts");
assignees.push("jkomyno");
} else if (category === "cli") {
labels.push("cli");
assignees.push("CryogenicPlanet");
} else if (category === "feature-request") {
labels.push("feature-request");
} else if (isSupport) {
labels.push("support");
} else if (category === "tool-request" && !suppressToolRequestHandling) {
labels.push("tool-request");
}
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({ owner, repo, name, color, description });
}
}
if (isSupport) {
await ensureLabel("support", "0e8a16", "Needs support team follow-up");
for (const staleLabel of ["bug", "ts"]) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: staleLabel });
} catch (error) {
if (error.status !== 404) throw error;
}
}
try {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number,
assignees: ["jkomyno"],
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels,
});
}
if (assignees.length > 0) {
await github.rest.issues.addAssignees({
owner,
repo,
issue_number,
assignees,
});
}
if (
!isSupport &&
(category !== "tool-request" || suppressToolRequestHandling) &&
shouldComment &&
commentBody.trim().length > 0
) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: commentBody.trim(),
});
}
async function plainGraphql(query, variables) {
const apiKey = process.env.PLAIN_API_KEY;
if (!apiKey) {
core.warning("PLAIN_API_KEY is not configured; skipping Plain support forwarding.");
return null;
}
const response = await fetch("https://core-api.uk.plain.com/graphql/v1", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
});
const json = await response.json().catch(() => ({}));
if (!response.ok || json.errors) {
core.warning(`Plain API request failed: ${JSON.stringify(json)}`);
return null;
}
return json.data;
}
async function getIssueAuthorPlainCustomer() {
const login = issue.user?.login || "unknown";
let publicEmail = issue.user?.email || null;
let displayName = issue.user?.name || login;
if (login !== "unknown") {
try {
const { data: user } = await github.rest.users.getByUsername({ username: login });
publicEmail = user.email || publicEmail;
displayName = user.name || displayName;
} catch (error) {
core.warning(`Could not fetch GitHub user profile for ${login}: ${error.message}`);
}
}
return {
email: publicEmail || (issue.user?.id ? `${issue.user.id}+${login}@users.noreply.github.com` : `${login}@users.noreply.github.com`),
fullName: displayName,
shortName: login,
externalId: `github:user:${login}`,
};
}
async function forwardSupportIssueToPlain() {
const plainCustomer = await getIssueAuthorPlainCustomer();
const customerData = await plainGraphql(
`mutation upsertCustomer($input: UpsertCustomerInput!) {
upsertCustomer(input: $input) {
result
customer { id }
error { message type code fields { field message type } }
}
}`,
{
input: {
identifier: { emailAddress: plainCustomer.email },
onCreate: {
fullName: plainCustomer.fullName,
shortName: plainCustomer.shortName,
email: { email: plainCustomer.email, isVerified: Boolean(issue.user?.email) },
externalId: plainCustomer.externalId,
},
onUpdate: {
fullName: { value: plainCustomer.fullName },
shortName: { value: plainCustomer.shortName },
externalId: { value: plainCustomer.externalId },
},
},
}
);
const customer = customerData?.upsertCustomer?.customer;
const customerError = customerData?.upsertCustomer?.error;
if (!customer?.id) {
core.warning(`Could not upsert Plain support customer: ${JSON.stringify(customerError)}`);
return null;
}
const body = issue.body || "_No issue body provided._";
const plainText = [
`GitHub issue routed as support: ${issue.html_url}`,
`Reporter: @${issue.user?.login || "unknown"}`,
`Classification reason: ${reason || "No reason provided."}`,
"",
"Issue body:",
body.length > 8000 ? `${body.slice(0, 8000)}\n\n[truncated]` : body,
].join("\n");
const threadData = await plainGraphql(
`mutation createThread($input: CreateThreadInput!) {
createThread(input: $input) {
thread { id ref }
error { message type code fields { field message type } }
}
}`,
{
input: {
title: `[GitHub #${issue.number}] ${issue.title}`,
customerIdentifier: { customerId: customer.id },
externalId: `github:${owner}/${repo}:issues/${issue.number}`,
components: [{ componentText: { text: plainText } }],
},
}
);
const thread = threadData?.createThread?.thread;
const threadError = threadData?.createThread?.error;
if (!thread?.id) {
core.warning(`Could not create Plain support thread: ${JSON.stringify(threadError)}`);
return null;
}
core.info(`Forwarded support issue #${issue.number} to Plain thread ${thread.ref || thread.id}`);
return thread;
}
if (isSupport) {
const thread = await forwardSupportIssueToPlain();
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: thread
? `🤖 Automated Claude triage note: this does not look like an SDK bug, so I've labeled it as \`support\` and forwarded it to Plain for the support team. Claude cannot reply back in a conversation.`
: `🤖 Automated Claude triage note: this does not look like an SDK bug, so I've labeled it as \`support\` for support team follow-up. Claude cannot reply back in a conversation.`,
});
}
if (category === "tool-request" && !suppressToolRequestHandling && issueState === "open") {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: [
"🤖 beep boop — hey, I think this is a tool request! Please file it at https://request.composio.dev/boards/tool-requests so the team can track and prioritize it.",
"",
"I've tagged this as `tool-request` and closed it here.",
"",
"If you think I got this wrong, feel free to reopen the issue.",
].join("\n"),
});
await github.rest.issues.update({
owner,
repo,
issue_number,
state: "closed",
});
}