Skip to content
Open
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
63 changes: 63 additions & 0 deletions .opencode/lib/github-pr-search.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Pure, dependency-free helpers for the github-pr-search tool.
// Kept in a separate module so they can be unit-tested without importing the
// plugin runtime (`@opencode-ai/plugin`).

export interface PR {
number: number
title: string
html_url: string
state: string
user?: { login?: string }
labels?: Array<{ name?: string }>
body?: string | null
pull_request?: { merged_at?: string | null }
}

/** GitHub Search returns state open/closed; a merged PR has pull_request.merged_at. */
export function describeState(pr: PR): string {
if (pr.pull_request?.merged_at) return "merged"
return pr.state ?? "unknown"
}

/** Collapse whitespace and truncate a PR body to a short snippet. */
export function snippet(body: string | null | undefined, max = 200): string {
if (!body) return ""
const flat = body.replace(/\s+/g, " ").trim()
return flat.length > max ? flat.slice(0, max) + "…" : flat
}

/** Clamp pagination inputs to the GitHub Search API's valid ranges. */
export function clampPaging(limit: number, page: number): { perPage: number; page: number } {
return {
perPage: Math.min(Math.max(Math.trunc(limit), 1), 100),
page: Math.max(Math.trunc(page), 1),
}
}

/** Build the GitHub Search `q` value. `state: "all"` omits the state qualifier. */
export function buildSearchQuery(
query: string,
owner: string,
repo: string,
state: "open" | "closed" | "all",
): string {
const stateFilter = state === "all" ? "" : ` state:${state}`
return `${query} repo:${owner}/${repo} type:pr${stateFilter}`
}

/** Render one PR into an LLM-friendly block. */
export function formatPr(pr: PR): string {
const author = pr.user?.login ? `@${pr.user.login}` : "unknown"
const labels = (pr.labels ?? [])
.map((l) => l.name)
.filter(Boolean)
.join(", ")
const lines = [
`#${pr.number} — ${pr.title}`,
`state: ${describeState(pr)} · author: ${author}${labels ? ` · labels: ${labels}` : ""}`,
pr.html_url,
]
const body = snippet(pr.body)
if (body) lines.push(body)
return lines.join("\n")
}
27 changes: 27 additions & 0 deletions .opencode/lib/github-triage.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Pure, dependency-free helpers for the github-triage tool, split out so they
// can be unit-tested without importing the plugin runtime (`@opencode-ai/plugin`).

export const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const

export type Team = keyof typeof TEAM

/** Pick a random element; throws a clear error instead of returning undefined for an empty list. */
export function pick<T>(items: readonly T[]): T {
if (items.length === 0) throw new Error("Cannot pick from an empty list")
return items[Math.floor(Math.random() * items.length)]!
}

/** Parse and validate an issue number from an env-like string. Must be a positive integer. */
export function parseIssueNumber(raw: string | undefined): number {
const issue = Number.parseInt((raw ?? "").trim(), 10)
if (!Number.isInteger(issue) || issue <= 0) {
throw new Error(`Invalid ISSUE_NUMBER: ${JSON.stringify(raw)}`)
}
return issue
}
73 changes: 73 additions & 0 deletions .opencode/test/github-pr-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { test, expect, describe } from "bun:test"
import { describeState, snippet, clampPaging, buildSearchQuery, formatPr, type PR } from "../lib/github-pr-search.lib"

describe("describeState", () => {
test("merged when pull_request.merged_at is set", () => {
expect(describeState({ pull_request: { merged_at: "2024-01-01" }, state: "closed" } as PR)).toBe("merged")
})
test("falls back to the GitHub state", () => {
expect(describeState({ state: "open" } as PR)).toBe("open")
})
test("unknown when state missing", () => {
expect(describeState({} as PR)).toBe("unknown")
})
})

describe("snippet", () => {
test("empty for null/empty body", () => {
expect(snippet(null)).toBe("")
expect(snippet("")).toBe("")
})
test("collapses whitespace", () => {
expect(snippet("a\n\n b c")).toBe("a b c")
})
test("truncates long bodies with an ellipsis", () => {
const out = snippet("x".repeat(250))
expect(out.length).toBe(201)
expect(out.endsWith("…")).toBe(true)
})
})

describe("clampPaging", () => {
test("passes valid values through", () => {
expect(clampPaging(10, 1)).toEqual({ perPage: 10, page: 1 })
})
test("clamps page below 1", () => {
expect(clampPaging(10, 0)).toEqual({ perPage: 10, page: 1 })
})
test("caps perPage at 100 and floors at 1", () => {
expect(clampPaging(500, 3)).toEqual({ perPage: 100, page: 3 })
expect(clampPaging(0, 1)).toEqual({ perPage: 1, page: 1 })
})
test("truncates fractional input", () => {
expect(clampPaging(10.9, 2.7)).toEqual({ perPage: 10, page: 2 })
})
})

describe("buildSearchQuery", () => {
test("includes the state qualifier", () => {
expect(buildSearchQuery("foo", "o", "r", "open")).toBe("foo repo:o/r type:pr state:open")
})
test("omits the state qualifier for 'all'", () => {
expect(buildSearchQuery("foo", "o", "r", "all")).toBe("foo repo:o/r type:pr")
})
})

describe("formatPr", () => {
test("renders number, state, author, labels, url and snippet", () => {
const pr = {
number: 7,
title: "Fix",
html_url: "u",
state: "open",
user: { login: "a" },
labels: [{ name: "bug" }],
body: "hi",
} as PR
expect(formatPr(pr)).toBe("#7 — Fix\nstate: open · author: @a · labels: bug\nu\nhi")
})
test("handles missing author and labels", () => {
const pr = { number: 1, title: "T", html_url: "u", state: "closed" } as PR
expect(formatPr(pr)).toBe("#1 — T\nstate: closed · author: unknown\nu")
})
})
34 changes: 34 additions & 0 deletions .opencode/test/github-triage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect, describe } from "bun:test"
import { pick, parseIssueNumber, TEAM } from "../lib/github-triage.lib"

describe("parseIssueNumber", () => {
test("parses a valid positive integer", () => {
expect(parseIssueNumber("123")).toBe(123)
})
test("trims surrounding whitespace", () => {
expect(parseIssueNumber(" 45 ")).toBe(45)
})
test("parses a leading number", () => {
expect(parseIssueNumber("12abc")).toBe(12)
})
test("throws on undefined, empty, zero, negative, and non-numeric", () => {
expect(() => parseIssueNumber(undefined)).toThrow()
expect(() => parseIssueNumber("")).toThrow()
expect(() => parseIssueNumber("0")).toThrow()
expect(() => parseIssueNumber("-5")).toThrow()
expect(() => parseIssueNumber("abc")).toThrow()
})
})

describe("pick", () => {
test("throws on an empty list instead of returning undefined", () => {
expect(() => pick([])).toThrow()
})
test("returns the only element of a single-item list", () => {
expect(pick(["only"])).toBe("only")
})
test("returns a member of the provided list", () => {
const member = pick(TEAM.tui)
expect(TEAM.tui.includes(member)).toBe(true)
})
})
33 changes: 17 additions & 16 deletions .opencode/tool/github-pr-search.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import { type PR, buildSearchQuery, clampPaging, formatPr } from "../lib/github-pr-search.lib"

async function githubFetch(endpoint: string, options: RequestInit = {}) {
const hasBody = options.body !== undefined && options.body !== null
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
// Only send Content-Type when there is actually a request body.
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
},
})
Expand All @@ -16,35 +20,32 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
return response.json()
}

interface PR {
title: string
html_url: string
}

export default tool({
description: `Use this tool to search GitHub pull requests by title and description.

This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results. For each PR it returns:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
- URL

Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
Pagination is page-based (1-indexed), matching the GitHub Search API exactly, so results never silently overlap or skip.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
limit: tool.schema.number().describe("Results per page (max 100)").default(10),
page: tool.schema.number().describe("1-indexed page number for pagination").default(1),
state: tool.schema.enum(["open", "closed", "all"]).describe("Filter by PR state").default("open"),
},
async execute(args) {
const owner = "anomalyco"
const repo = "opencode"

const page = Math.floor(args.offset / args.limit) + 1
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`)
const { perPage, page } = clampPaging(args.limit, args.page)
const searchQuery = encodeURIComponent(buildSearchQuery(args.query, owner, repo, args.state))
const result = await githubFetch(
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
`/search/issues?q=${searchQuery}&per_page=${perPage}&page=${page}&sort=updated&order=desc`,
)

if (result.total_count === 0) {
Expand All @@ -54,11 +55,11 @@ Use the query parameter to search for keywords that might appear in PR titles or
const prs = result.items as PR[]

if (prs.length === 0) {
return `No other PRs found matching "${args.query}"`
return `No PRs on page ${page} matching "${args.query}" (${result.total_count} total)`
}

const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
const formatted = prs.map(formatPr).join("\n\n")

return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
return `Found ${result.total_count} PRs (page ${page}, showing ${prs.length}):\n\n${formatted}`
},
})
29 changes: 6 additions & 23 deletions .opencode/tool/github-triage.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,16 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"

const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const

function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}

function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
return issue
}
import { TEAM, type Team, pick, parseIssueNumber } from "../lib/github-triage.lib"

async function githubFetch(endpoint: string, options: RequestInit = {}) {
const hasBody = options.body !== undefined && options.body !== null
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
// Only send Content-Type when there is actually a request body.
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
},
})
Expand All @@ -40,12 +25,10 @@ export default tool({

Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
args: {
team: tool.schema
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
.describe("The owning team"),
team: tool.schema.enum(Object.keys(TEAM) as [Team, ...Team[]]).describe("The owning team"),
},
async execute(args) {
const issue = getIssueNumber()
const issue = parseIssueNumber(process.env.ISSUE_NUMBER)
const owner = "anomalyco"
const repo = "opencode"
const assignee = pick(TEAM[args.team])
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export type Ruleset = typeof Ruleset.Type

const EDIT_TOOLS = ["edit", "write", "apply_patch"]

/**
* Resolve the effective rule for a (permission, pattern) pair.
*
* Rules are flattened across rulesets and the LAST matching rule wins
* ("last match wins"), so more specific or later rules override earlier ones.
* A rule matches when both its `permission` glob matches `permission` AND its
* `pattern` glob matches `pattern`. When nothing matches, the default is `ask`.
*/
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
Expand All @@ -34,6 +42,22 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}

/**
* Determine which tools should be fully hidden/disabled in the UI.
*
* INTENTIONAL semantics (see permission-task.test.ts): a tool is disabled only
* when the LAST rule whose `permission` glob matches the tool is a blanket deny
* (`pattern === "*" && action === "deny"`). Consequences worth knowing:
* - A narrower allow that comes AFTER a blanket deny re-enables the tool
* (the last matching rule is no longer the `*` deny).
* - Conversely, narrower allows that come BEFORE a trailing blanket `*` deny do
* NOT keep the tool enabled — the blanket deny still hides it, even though
* `evaluate()` may still permit specific patterns at call time. This asymmetry
* with `evaluate()` is deliberate: `disabled()` is a coarse UI gate, while
* `evaluate()` is the precise per-call check.
* Edit-family tools (edit/write/apply_patch) are collapsed onto the `edit`
* permission before matching.
*/
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/util/wildcard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
export * as Wildcard from "./wildcard"

/**
* Glob-style matcher used for permission rules and path matching.
*
* Semantics (intentional):
* - `*` matches any run of characters, `?` matches a single character.
* - Backslashes are normalized to `/` on both sides so Windows and POSIX paths
* compare equally.
* - A trailing ` *` (space + star) is made optional, so a rule like `git push *`
* also matches the bare command `git push`.
* - Case sensitivity is platform-dependent: case-INsensitive on Windows (`si`)
* and case-SENSITIVE elsewhere (`s`). This mirrors real OS behavior — Windows
* paths and commands are case-insensitive, POSIX ones are not — so a deny rule
* such as `RM *` intentionally does NOT match `rm` on Linux/macOS. Author rules
* in the casing that matches the target platform.
*/
export function match(input: string, pattern: string) {
const normalized = input.replaceAll("\\", "/")
let escaped = pattern
Expand Down
Loading
Loading