Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/assets/clawreview-avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions .github/workflows/issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:
contents: read
# Skip issues opened by bots to avoid loops.
if: ${{ !endsWith(github.actor, '[bot]') }}
# Job-level so a step `if:` can read it — a step cannot see env it sets on
# itself. Gates the optional App-token step below.
env:
HAS_APP: ${{ secrets.CLAWREVIEW_APP_ID }}
steps:
- name: Checkout
# SHA-pinned: this workflow runs on every opened issue with
Expand Down Expand Up @@ -63,12 +67,12 @@ jobs:
# is used. The bot works either way.
id: app-token
if: env.HAS_APP != ''
env:
HAS_APP: ${{ secrets.CLAWREVIEW_APP_ID }}
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.CLAWREVIEW_APP_ID }}
private-key: ${{ secrets.CLAWREVIEW_APP_PRIVATE_KEY }}
# Triage only labels + comments on issues — scope the token to that.
permission-issues: write

- name: Triage with Claude
env:
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jobs:
# Drafts are skipped too (reviewed at ready_for_review) — no API spend
# while the author is still iterating.
if: ${{ !endsWith(github.event.pull_request.user.login, '[bot]') && !github.event.pull_request.draft }}
# Job-level so a step `if:` can read it — a step cannot see env it sets on
# itself. Gates the optional App-token step below.
env:
HAS_APP: ${{ secrets.CLAWREVIEW_APP_ID }}
steps:
- name: Checkout (base repo only — never the PR head)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
Expand Down Expand Up @@ -66,12 +70,14 @@ jobs:
# github-actions[bot] — the bot works either way.
id: app-token
if: env.HAS_APP != ''
env:
HAS_APP: ${{ secrets.CLAWREVIEW_APP_ID }}
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.CLAWREVIEW_APP_ID }}
private-key: ${{ secrets.CLAWREVIEW_APP_PRIVATE_KEY }}
# Scope the token to exactly what this bot needs (comments + labels),
# not the App's full installation grant.
permission-pull-requests: write
permission-issues: write

- name: Review with Claude
env:
Expand Down
112 changes: 39 additions & 73 deletions scripts/issue-triage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// Needs: ANTHROPIC_API_KEY (repo secret) and GH_TOKEN (the workflow's GITHUB_TOKEN).
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import Anthropic from "@anthropic-ai/sdk";
import { callClaude } from "./lib/ai-backend.mjs";

// Haiku 4.5 — fast and cheap, ideal for a high-volume issue classifier.
// Switch to "claude-opus-4-8" for maximum classification accuracy.
Expand Down Expand Up @@ -42,61 +42,11 @@ function gh(args) {
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
}

// Extract a JSON object from possibly-fenced/wrapped model text.
function parseModelJson(text) {
const stripped = text.replace(/^```(?:json)?\s*/m, "").replace(/```\s*$/m, "").trim();
try { return JSON.parse(stripped); } catch { /* fall through */ }
const m = stripped.match(/\{[\s\S]*\}/);
if (!m) throw new Error("no JSON object in model response");
return JSON.parse(m[0]);
}

// Subscription transport: headless Claude Code CLI, authed by
// CLAUDE_CODE_OAUTH_TOKEN (the official Pro/Max path). Keep in sync with the
// same pattern in scripts/pr-review.mjs.
function classifyViaClaudeCli(userContent) {
const prompt = [
SYSTEM,
"\nRespond with ONLY a JSON object matching this schema (no prose, no fences):",
JSON.stringify(SCHEMA),
"\n---\n",
userContent,
].join("\n");
const out = execFileSync("claude", ["-p", "--model", MODEL, "--output-format", "json"], {
encoding: "utf8",
input: prompt,
stdio: ["pipe", "pipe", "inherit"],
timeout: 180_000,
maxBuffer: 8 * 1024 * 1024,
});
const wrapper = JSON.parse(out);
if (wrapper.is_error) throw new Error(`claude cli error: ${String(wrapper.result).slice(0, 200)}`);
return parseModelJson(String(wrapper.result));
}

async function classifyViaSdk(userContent) {
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const resp = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: SYSTEM,
output_config: { format: { type: "json_schema", schema: SCHEMA } },
messages: [{ role: "user", content: userContent }],
});
const text = resp.content.find((b) => b.type === "text")?.text;
// Throw rather than default to "{}" — an empty object here would create
// and apply labels literally named "undefined"; the outer catch logs and
// exits 0 (triage must never fail issue creation).
if (!text) throw new Error("no text block in model response");
return JSON.parse(text);
}

async function main() {
const userContent = `Triage this issue. Respond ONLY with the JSON object.\n\n<title>${title}</title>\n\n<body>\n${body.slice(0, 8000)}\n</body>`;
// Subscription OAuth preferred (team choice); API key as fallback backend.
const t = process.env.CLAUDE_CODE_OAUTH_TOKEN
? classifyViaClaudeCli(userContent)
: await classifyViaSdk(userContent);
// Transport (OAuth CLI / SDK) lives in the shared backend so both bots stay
// in sync. OAuth is preferred; the API-key SDK is the fallback.
const t = await callClaude({ system: SYSTEM, schema: SCHEMA, userContent, model: MODEL, maxTokens: 1024, timeoutMs: 180_000, maxBuffer: 8 * 1024 * 1024 });

// Ensure the priority/area labels exist (idempotent), then apply.
const ensure = (name, color, desc) => {
Expand All @@ -109,34 +59,50 @@ async function main() {
console.log(`label ensure '${name}':`, err?.message?.split("\n")[0] ?? err);
}
};
const prioColor = t.priority === "high" ? "b60205" : t.priority === "medium" ? "fbca04" : "0e8a16";
ensure(`priority: ${t.priority}`, prioColor, "Auto-triage priority");
ensure(`area: ${t.area}`, "c5def5", "Auto-triage area");
// `gh issue edit` applies all labels in one call and fails the whole command
// if ANY is missing — so the category label must exist too, even though
// bug/enhancement/etc. are GitHub defaults (a repo may have deleted them).
const catColor = { bug: "d73a4a", enhancement: "a2eeef", documentation: "0075ca", question: "d876e3", invalid: "e4e669" }[t.category] ?? "ededed";
ensure(t.category, catColor, "Auto-triage category");

const labels = [t.category, `priority: ${t.priority}`, `area: ${t.area}`];
gh(["issue", "edit", String(number), "--repo", REPO, ...labels.flatMap((l) => ["--add-label", l])]);
// All label creation + application mutates the repo — gate the whole block
// on DRY_RUN so a dry run stays fully read-only.
if (!process.env.DRY_RUN) {
const prioColor = t.priority === "high" ? "b60205" : t.priority === "medium" ? "fbca04" : "0e8a16";
ensure(`priority: ${t.priority}`, prioColor, "Auto-triage priority");
ensure(`area: ${t.area}`, "c5def5", "Auto-triage area");
// `gh issue edit` applies all labels in one call and fails the whole command
// if ANY is missing — so the category label must exist too, even though
// bug/enhancement/etc. are GitHub defaults (a repo may have deleted them).
const catColor = { bug: "d73a4a", enhancement: "a2eeef", documentation: "0075ca", question: "d876e3", invalid: "e4e669" }[t.category] ?? "ededed";
ensure(t.category, catColor, "Auto-triage category");
const labels = [t.category, `priority: ${t.priority}`, `area: ${t.area}`];
gh(["issue", "edit", String(number), "--repo", REPO, ...labels.flatMap((l) => ["--add-label", l])]);
}

// Same crab mascot as ClawReview (the PR bot) — one friendly character
// across issues and PRs. Greeting picked by issue number for stability.
const GREETINGS = [
"Scuttled over to help sort this one 🦀",
"Your friendly reef crab, here to get this filed.",
"Thanks for the report — let me get you oriented.",
"Claws on the case. Here's how I've tagged it:",
];
const priIcon = { high: "🔴", medium: "🟡", low: "🟢" }[t.priority] ?? "⚪";
const comment = [
"### 🤖 Auto-triage",
"## 🦀 ClawReview",
"",
`*${GREETINGS[number % GREETINGS.length]}*`,
"",
"| | |",
"|---|---|",
`| **Category** | \`${t.category}\` |`,
`| **Priority** | \`${t.priority}\` |`,
`| **Area** | \`${t.area}\` |`,
t.summary,
"",
`**Summary:** ${t.summary}`,
"**At a glance**",
`- Category: \`${t.category}\` · Area: \`${t.area}\``,
`- Priority: ${priIcon} \`${t.priority}\``,
"",
`**Suggested next step:** ${t.suggested_action}`,
"",
"<sub>Auto-classified on open — labels are advisory; adjust as needed.</sub>",
"<sub>— ClawReview 🦀. Labels auto-applied on open — advisory, a maintainer will follow up. Conventions: <a href=\"https://docs.clawbox.tech/llms.txt\">docs</a>.</sub>",
].join("\n");

if (process.env.DRY_RUN) {
console.log(comment);
return;
}
gh(["issue", "comment", String(number), "--repo", REPO, "--body", comment]);
console.log(`Triaged #${number}: ${t.category} / ${t.priority} / ${t.area}`);
}
Expand Down
62 changes: 62 additions & 0 deletions scripts/lib/ai-backend.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Shared Claude backend for the ClawBox CI bots (pr-review + issue-triage).
// One place for the two transports so a change to either stays in sync:
// - CLAUDE_CODE_OAUTH_TOKEN set -> Claude Code CLI (`claude -p`), the
// official Pro/Max subscription path (same runtime as claude-code-action)
// - else -> the Anthropic SDK (API key)
// The SDK is imported lazily so the OAuth-only install (CLI, no SDK) doesn't
// crash at module load.
import { execFileSync } from "node:child_process";

// Extract a JSON object from possibly-fenced/wrapped model text.
export function parseModelJson(text) {
const stripped = text.replace(/^```(?:json)?\s*/m, "").replace(/```\s*$/m, "").trim();
try { return JSON.parse(stripped); } catch { /* fall through */ }
const m = stripped.match(/\{[\s\S]*\}/);
if (!m) throw new Error("no JSON object in model response");
return JSON.parse(m[0]);
}

function viaClaudeCli({ system, schema, userContent, model, timeoutMs, maxBuffer }) {
const prompt = [
system,
"\nRespond with ONLY a JSON object matching this schema (no prose, no fences):",
JSON.stringify(schema),
"\n---\n",
userContent,
].join("\n");
const out = execFileSync("claude", ["-p", "--model", model, "--output-format", "json"], {
encoding: "utf8",
input: prompt,
stdio: ["pipe", "pipe", "inherit"],
timeout: timeoutMs,
maxBuffer,
});
const wrapper = JSON.parse(out);
if (wrapper.is_error) throw new Error(`claude cli error: ${String(wrapper.result).slice(0, 200)}`);
return parseModelJson(String(wrapper.result));
}

async function viaSdk({ system, schema, userContent, model, maxTokens }) {
const { default: Anthropic } = await import("@anthropic-ai/sdk");
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const resp = await client.messages.create({
model,
max_tokens: maxTokens,
system,
output_config: { format: { type: "json_schema", schema } },
messages: [{ role: "user", content: userContent }],
});
const text = resp.content.find((b) => b.type === "text")?.text;
// Throw rather than default to "{}" — an empty object downstream would create
// and apply labels literally named "undefined". Callers' outer catch exits 0.
if (!text) throw new Error("no text block in model response");
// Tolerant parse (same as the CLI path) in case the model fences the JSON.
return parseModelJson(text);
}

// Run one structured-output call and return the validated JSON object.
// OAuth transport preferred; API-key SDK is the fallback.
export function callClaude(opts) {
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return viaClaudeCli(opts);
return viaSdk(opts);
}
Loading
Loading