diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a4c8454a9d..03df339cb8 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/qwen3.6-plus +model: opencode/gpt-5.4-nano color: "#44BA81" tools: "*": false @@ -14,7 +14,11 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -Assign issues by choosing the team with the strongest overlap, then assign a member from that team at random. +Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. + +Do not add labels to issues. Only assign an owner. + +When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. ## Teams @@ -22,34 +26,18 @@ Assign issues by choosing the team with the strongest overlap, then assign a mem Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -- kommander -- simonklee - ### Desktop / Web Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -- Hona -- Brendonovich - ### Core -Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, and larger architectural features. - -- jlongster -- rekram1-node -- nexxeln -- kitlangton +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. ### Inference OpenCode Zen, OpenCode Go, and billing issues. -- fwang -- MrMushrooooom - ### Windows Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. - -- Hona diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 56886808a4..e03b1fdd9c 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,16 +1,14 @@ /// import { tool } from "@opencode-ai/plugin" + const TEAM = { - desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], - zen: ["fwang", "MrMushrooooom"], - tui: ["kommander", "rekram1-node", "simonklee"], - core: ["kitlangton", "rekram1-node", "jlongster"], - docs: ["R44VC0RP"], + tui: ["kommander", "simonklee"], + desktop_web: ["Hona", "Brendonovich"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + inference: ["fwang", "MrMushrooooom"], windows: ["Hona"], } as const -const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] - function pick(items: readonly T[]) { return items[Math.floor(Math.random() * items.length)]! } @@ -38,79 +36,23 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: `Use this tool to assign and/or label a GitHub issue. + description: `Use this tool to assign a GitHub issue. -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - assignee: tool.schema - .enum(ASSIGNEES as [string, ...string[]]) - .describe("The username of the assignee") - .default("rekram1-node"), - labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) - .describe("The labels(s) to add to the issue") - .default([]), + team: tool.schema.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]).describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() const owner = "anomalyco" const repo = "opencode" - - const results: string[] = [] - let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] - const web = labels.includes("web") - const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() - const zen = /\bzen\b/.test(text) || text.includes("opencode black") - const nix = /\bnix(os)?\b/.test(text) - - if (labels.includes("nix") && !nix) { - labels = labels.filter((x) => x !== "nix") - results.push("Dropped label: nix (issue does not mention nix)") - } - - const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee - - if (labels.includes("zen") && !zen) { - throw new Error("Only add the zen label when issue title/body contains 'zen'") - } - - if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { - throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") - } - - if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") - } - - if (assignee === "Hona" && !labels.includes("windows")) { - throw new Error("Only windows issues should be assigned to Hona") - } - - if (assignee === "R44VC0RP" && !labels.includes("docs")) { - throw new Error("Only docs issues should be assigned to R44VC0RP") - } - - if (assignee === "kommander" && !labels.includes("opentui")) { - throw new Error("Only opentui issues should be assigned to kommander") - } + const assignee = pick(TEAM[args.team]) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${assignee} to issue #${issue}`) - if (labels.length > 0) { - await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { - method: "POST", - body: JSON.stringify({ labels }), - }) - results.push(`Added labels: ${labels.join(", ")}`) - } - - return results.join("\n") + return `Assigned @${assignee} from ${args.team} to issue #${issue}` }, }) diff --git a/script/triage-unassigned.ts b/script/triage-unassigned.ts new file mode 100644 index 0000000000..a71c6af318 --- /dev/null +++ b/script/triage-unassigned.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util" + +async function run(command: string, args: string[], options: Bun.SpawnOptions.OptionsObject = {}) { + const process = Bun.spawn([command, ...args], options) + const status = await process.exited + if (status !== 0) throw new Error(`${command} ${args.join(" ")} exited with ${status}`) + return process +} + +async function text(command: string, args: string[]) { + const process = await run(command, args, { stdout: "pipe", stderr: "inherit" }) + return new Response(process.stdout).text() +} + +async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + days: { type: "string", short: "d", default: "30" }, + limit: { type: "string", short: "l", default: "200" }, + "dry-run": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + }) + + if (values.help) { + console.log(` +Usage: bun script/triage-unassigned.ts [options] + +Triage open GitHub issues created in the last 30 days with no assignee. + +Options: + -d, --days Look back this many days (default: 30) + -l, --limit Maximum issues to process (default: 200) + --dry-run Print matching issues without running triage + -h, --help Show this help message + +Examples: + bun script/triage-unassigned.ts + bun script/triage-unassigned.ts --limit 3 + bun script/triage-unassigned.ts --dry-run +`) + process.exit(0) + } + + const days = Number(values.days) + const limit = Number(values.limit) + if (!Number.isInteger(days) || days < 1) throw new Error("--days must be a positive integer") + if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer") + + const created = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const query = `no:assignee created:>=${created}` + const issues = JSON.parse( + await text("gh", [ + "issue", + "list", + "--state", + "open", + "--search", + query, + "--limit", + String(limit), + "--json", + "number,title,body", + ]), + ) as Array<{ number: number; title: string; body?: string | null }> + + console.log(`Found ${issues.length} open unassigned issues created since ${created}`) + if (issues.length === 0) return + + if (values["dry-run"]) { + for (const issue of issues) console.log(`#${issue.number} ${issue.title}`) + return + } + + const githubToken = process.env.GITHUB_TOKEN || (await text("gh", ["auth", "token"])).trim() + const failures: Array<{ issue: number; error: string }> = [] + + for (const [index, issue] of issues.entries()) { + console.log(`\n[${index + 1}/${issues.length}] Triaging #${issue.number} ${issue.title}`) + const result = Bun.spawn( + [ + "opencode", + "run", + "--agent", + "triage", + `The following issue was just opened, triage it: + +Issue: #${issue.number} +Title: ${issue.title} + +Body: +${issue.body ?? ""}`, + ], + { + env: { + ...process.env, + GITHUB_TOKEN: githubToken, + ISSUE_NUMBER: String(issue.number), + ISSUE_TITLE: issue.title, + ISSUE_BODY: issue.body ?? "", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ) + const status = await result.exited + + if (status === 0) { + console.log(`[${index + 1}/${issues.length}] Done #${issue.number}`) + continue + } + + failures.push({ issue: issue.number, error: `opencode exited with ${status}` }) + console.error(`[${index + 1}/${issues.length}] Failed #${issue.number}: opencode exited with ${status}`) + } + + console.log(`\nFinished triaging ${issues.length - failures.length}/${issues.length} issues`) + if (failures.length === 0) return + + console.error("Failures:") + for (const failure of failures) console.error(`#${failure.issue}: ${failure.error}`) + process.exit(1) +} + +void main()