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()