diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 87e655fe4b..bc393269f9 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -18,100 +18,65 @@ jobs: - uses: ./.github/actions/setup-bun + - name: Install dependencies + run: bun install + - name: Install opencode run: curl -fsSL https://opencode.ai/install | bash + - name: Build prompt + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs") + const issue = context.payload.issue + const body = issue.body ?? "" + const text = [ + "Check this new issue for compliance and duplicates:", + "", + `CURRENT_ISSUE_NUMBER: ${issue.number}`, + "", + `Title: ${issue.title}`, + "", + "Description:", + body, + ].join("\n") + + fs.writeFileSync("issue_info.txt", text) + - name: Check duplicates and compliance env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow" - }, - "webfetch": "deny" - } run: | - opencode run -m opencode/claude-haiku-4-5 "A new issue has been created: + bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details and return either a comment body or No action required" > issue_comment.txt - Issue number: ${{ github.event.issue.number }} + - name: Post comment and label issue + env: + COMMENT: ${{ github.workspace }}/issue_comment.txt + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs") + const comment = fs.readFileSync(process.env.COMMENT, "utf8").trim() - Lookup this issue with gh issue view ${{ github.event.issue.number }}. + if (comment === "No action required") { + core.info("No comment needed") + return + } - You have TWO tasks. Perform both, then post a SINGLE comment (if needed). + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: `_The following comment was made by an LLM, it may be inaccurate:_\n\n${comment}`, + }) - --- + if (!comment.includes("")) return - TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK - - Check whether the issue follows our contributing guidelines and issue templates. - - This project has three issue templates that every issue MUST use one of: - - 1. Bug Report - requires a Description field with real content - 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: - 3. Question - requires the Question field with real content - - Additionally check: - - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) - - The issue has real content, not just template placeholder text left unchanged - - Bug reports should include some context about how to reproduce - - Feature requests should explain the problem or need - - We want to push for having the user provide system description & information - - Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. - - --- - - TASK 2: DUPLICATE CHECK - - Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates. - Consider: - 1. Similar titles or descriptions - 2. Same error messages or symptoms - 3. Related functionality or components - 4. Similar feature requests - - Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997. - - --- - - POSTING YOUR COMMENT: - - Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - - If the issue is NOT compliant, start the comment with: - - Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance - - If duplicates were found, include a section about potential duplicates with links. - - If the issue mentions keybinds/keyboard shortcuts, include a note about #4997. - - If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all. - - Use this format for the comment: - - [If not compliant:] - - This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md). - - **What needs to be fixed:** - - [specific reasons] - - Please edit this issue to address the above within **2 hours**, or it will be automatically closed. - - [If duplicates found, add:] - --- - This issue might be a duplicate of existing issues. Please check: - - #[issue_number]: [brief description of similarity] - - [If keybind-related, add:] - For keybind-related issues, please also check our pinned keybinds documentation: #4997 - - [End with if not compliant:] - If you believe this was flagged incorrectly, please let a maintainer know. - - Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + labels: ["needs:compliance"], + }) diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 397f794a1c..e6929af2cd 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -16,6 +16,7 @@ jobs: github.event.pull_request.user.login != 'fwang' && github.event.pull_request.user.login != 'adamdotdevin' && github.event.pull_request.user.login != 'iamdavidhill' && + github.event.pull_request.user.login != 'R44VC0RP' && github.event.pull_request.user.login != 'opencode-agent[bot]' runs-on: ubuntu-latest permissions: diff --git a/.opencode/agent/duplicate-issue.md b/.opencode/agent/duplicate-issue.md new file mode 100644 index 0000000000..efad62b455 --- /dev/null +++ b/.opencode/agent/duplicate-issue.md @@ -0,0 +1,86 @@ +--- +mode: primary +hidden: true +model: opencode/claude-haiku-4-5 +color: "#E67E22" +tools: + "*": false + "github-issue-search": true +--- + +You are a duplicate issue detection agent. When an issue is opened, your job is to search for potentially duplicate or related open issues. + +You have two jobs: + +1. Check if the issue follows our issue templates/contributing requirements. +2. Check for potential duplicate issues. + +Use the github-issue-search tool to find potentially related issues. + +IMPORTANT: The input will contain a line `CURRENT_ISSUE_NUMBER: NNNN`. Never mark that issue as a duplicate of itself. + +## Compliance checks + +This project has three issue templates: + +1. Bug Report - needs a Description field with real content. +2. Feature Request - title should start with `[FEATURE]:` and include verification checkbox + meaningful description. +3. Question - needs a Question field with real content. + +Also check: + +- no AI-generated walls of text +- required sections are not placeholder-only / unchanged template text +- bug reports include some repro context +- feature requests explain the problem/need +- encourage system information where relevant + +Do not be nitpicky about optional fields. Only flag real issues (missing template/required content, placeholder-only content, obviously AI-generated wall of text, empty/nonsensical issue). + +## Duplicate checks + +Search for duplicates by trying multiple keyword combinations from the issue title/body. Prioritize: + +- similar title/description +- same error/symptoms +- same component/feature area + +If the issue mentions keybinds, keyboard shortcuts, or key bindings, include a note to check pinned issue #4997. + +## Output rules + +If the issue is compliant AND no duplicates are found AND no keybind note is needed, output exactly: + +No action required + +Otherwise output exactly one markdown comment body with this structure: + +- If non-compliant, start with: + + + +This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md). + +**What needs to be fixed:** + +- [specific reason] + +Please edit this issue to address the above within **2 hours**, or it will be automatically closed. + +- If duplicates were found, add: + +--- + +This issue might be a duplicate of existing issues. Please check: + +- #1234: [brief reason] + +- If keybind-related, add: + +For keybind-related issues, please also check our pinned keybinds documentation: #4997 + +- If non-compliant, end with: + +If you believe this was flagged incorrectly, please let a maintainer know. + +Keep output concise. Do not wrap output in code fences. diff --git a/.opencode/tool/github-issue-search.ts b/.opencode/tool/github-issue-search.ts new file mode 100644 index 0000000000..e028446c2a --- /dev/null +++ b/.opencode/tool/github-issue-search.ts @@ -0,0 +1,57 @@ +/// +import { tool } from "@opencode-ai/plugin" +import DESCRIPTION from "./github-issue-search.txt" + +async function githubFetch(endpoint: string, options: RequestInit = {}) { + 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", + ...options.headers, + }, + }) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +interface Issue { + title: string + html_url: string +} + +export default tool({ + description: DESCRIPTION, + args: { + query: tool.schema.string().describe("Search query for issue 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), + }, + 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:issue state:open`) + const result = await githubFetch( + `/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`, + ) + + if (result.total_count === 0) { + return `No issues found matching "${args.query}"` + } + + const issues = result.items as Issue[] + + if (issues.length === 0) { + return `No other issues found matching "${args.query}"` + } + + const formatted = issues.map((issue) => `${issue.title}\n${issue.html_url}`).join("\n\n") + + return `Found ${result.total_count} issues (showing ${issues.length}):\n\n${formatted}` + }, +}) diff --git a/.opencode/tool/github-issue-search.txt b/.opencode/tool/github-issue-search.txt new file mode 100644 index 0000000000..176e916084 --- /dev/null +++ b/.opencode/tool/github-issue-search.txt @@ -0,0 +1,10 @@ +Use this tool to search GitHub issues by title and description. + +This tool searches issues in the sst/opencode repository and returns LLM-friendly results including: +- Issue number and title +- Author +- State (open/closed/merged) +- Labels +- Description snippet + +Use the query parameter to search for keywords that might appear in issue titles or descriptions. diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 3c28331bd5..8d156c03d8 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -6,6 +6,7 @@ import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" import { Project } from "../../project/project" import { MCP } from "../../mcp" +import { Session } from "../../session" import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() => return c.json(true) }, ) + .get( + "/session", + describeRoute({ + summary: "List sessions", + description: + "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + operationId: "experimental.session.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Session.GlobalInfo.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), + start: z.coerce + .number() + .optional() + .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), + cursor: z.coerce + .number() + .optional() + .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), + search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), + limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), + archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const limit = query.limit ?? 100 + const sessions: Session.GlobalInfo[] = [] + for await (const session of Session.listGlobal({ + directory: query.directory, + roots: query.roots, + start: query.start, + cursor: query.cursor, + search: query.search, + limit: limit + 1, + archived: query.archived, + })) { + sessions.push(session) + } + const hasMore = sessions.length > limit + const list = hasMore ? sessions.slice(0, limit) : sessions + if (hasMore && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) + }, + ) .get( "/resource", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 255f4dd460..f8e59b6d91 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -10,8 +10,10 @@ import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import type { SQL } from "../storage/db" import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" import { MessageV2 } from "./message-v2" @@ -154,6 +156,24 @@ export namespace Session { }) export type Info = z.output + export const ProjectInfo = z + .object({ + id: z.string(), + name: z.string().optional(), + worktree: z.string(), + }) + .meta({ + ref: "ProjectSummary", + }) + export type ProjectInfo = z.output + + export const GlobalInfo = Info.extend({ + project: ProjectInfo.nullable(), + }).meta({ + ref: "GlobalSession", + }) + export type GlobalInfo = z.output + export const Event = { Created: BusEvent.define( "session.created", @@ -544,6 +564,75 @@ export namespace Session { } } + export function* listGlobal(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }) { + const conditions: SQL[] = [] + + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + if (input?.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input?.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input?.cursor) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } + if (input?.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + if (!input?.archived) { + conditions.push(isNull(SessionTable.time_archived)) + } + + const limit = input?.limit ?? 100 + + const rows = Database.use((db) => { + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + }) + + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + + for (const row of rows) { + const project = projects.get(row.project_id) ?? null + yield { ...fromRow(row), project } + } + } + export const children = fn(Identifier.schema("session"), async (parentID) => { const project = Instance.project const rows = Database.use((db) => diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts new file mode 100644 index 0000000000..05d6de04b1 --- /dev/null +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("Session.listGlobal", () => { + test("lists sessions across projects with project metadata", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + + const firstSession = await Instance.provide({ + directory: first.path, + fn: async () => Session.create({ title: "first-session" }), + }) + const secondSession = await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "second-session" }), + }) + + const sessions = [...Session.listGlobal({ limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(ids).toContain(firstSession.id) + expect(ids).toContain(secondSession.id) + + const firstProject = Project.get(firstSession.projectID) + const secondProject = Project.get(secondSession.projectID) + + const firstItem = sessions.find((session) => session.id === firstSession.id) + const secondItem = sessions.find((session) => session.id === secondSession.id) + + expect(firstItem?.project?.id).toBe(firstProject?.id) + expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) + expect(secondItem?.project?.id).toBe(secondProject?.id) + expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) + }) + + test("excludes archived sessions by default", async () => { + await using tmp = await tmpdir({ git: true }) + + const archived = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "archived-session" }), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }), + }) + + const sessions = [...Session.listGlobal({ limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(ids).not.toContain(archived.id) + + const allSessions = [...Session.listGlobal({ limit: 200, archived: true })] + const allIds = allSessions.map((session) => session.id) + + expect(allIds).toContain(archived.id) + }) + + test("supports cursor pagination", async () => { + await using tmp = await tmpdir({ git: true }) + + const first = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "page-one" }), + }) + await new Promise((resolve) => setTimeout(resolve, 5)) + const second = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "page-two" }), + }) + + const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })] + expect(page.length).toBe(1) + expect(page[0].id).toBe(second.id) + + const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] + const ids = next.map((session) => session.id) + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }) +}) diff --git a/script/duplicate-issue.ts b/script/duplicate-issue.ts new file mode 100644 index 0000000000..8e47f6fbb6 --- /dev/null +++ b/script/duplicate-issue.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env bun + +import path from "path" +import { pathToFileURL } from "bun" +import { createOpencode } from "@opencode-ai/sdk" +import { parseArgs } from "util" + +async function main() { + const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + options: { + file: { type: "string", short: "f" }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + }) + + if (values.help) { + console.log(` +Usage: bun script/duplicate-issue.ts [options] + +Options: + -f, --file File to attach to the prompt + -h, --help Show this help message + +Examples: + bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details" +`) + process.exit(0) + } + + const message = positionals.join(" ") + if (!message) { + console.error("Error: message is required") + process.exit(1) + } + + const opencode = await createOpencode({ port: 0 }) + + try { + const parts: Array<{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }> = + [] + + if (values.file) { + const resolved = path.resolve(process.cwd(), values.file) + const file = Bun.file(resolved) + if (!(await file.exists())) { + console.error(`Error: file not found: ${values.file}`) + process.exit(1) + } + parts.push({ + type: "file", + url: pathToFileURL(resolved).href, + filename: path.basename(resolved), + mime: "text/plain", + }) + } + + parts.push({ type: "text", text: message }) + + const session = await opencode.client.session.create() + const result = await opencode.client.session + .prompt({ + path: { id: session.data!.id }, + body: { + agent: "duplicate-issue", + parts, + }, + signal: AbortSignal.timeout(120_000), + }) + .then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? "") + + console.log(result.trim()) + } finally { + opencode.server.close() + } +} + +main()