mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 838523317f | |||
| 9ce7afdb5d | |||
| 84643f4192 | |||
| 34e69735cb | |||
| b9ec4e6b97 | |||
| 007e633465 | |||
| c0e0156680 |
@@ -18,100 +18,65 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
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
|
- name: Check duplicates and compliance
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENCODE_PERMISSION: |
|
|
||||||
{
|
|
||||||
"bash": {
|
|
||||||
"*": "deny",
|
|
||||||
"gh issue*": "allow"
|
|
||||||
},
|
|
||||||
"webfetch": "deny"
|
|
||||||
}
|
|
||||||
run: |
|
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("<!-- issue-compliance -->")) return
|
||||||
|
|
||||||
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
Check whether the issue follows our contributing guidelines and issue templates.
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
This project has three issue templates that every issue MUST use one of:
|
labels: ["needs:compliance"],
|
||||||
|
})
|
||||||
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:
|
|
||||||
<!-- issue-compliance -->
|
|
||||||
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:]
|
|
||||||
<!-- issue-compliance -->
|
|
||||||
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."
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ jobs:
|
|||||||
github.event.pull_request.user.login != 'fwang' &&
|
github.event.pull_request.user.login != 'fwang' &&
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
github.event.pull_request.user.login != 'adamdotdevin' &&
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
github.event.pull_request.user.login != 'iamdavidhill' &&
|
||||||
|
github.event.pull_request.user.login != 'R44VC0RP' &&
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
<!-- issue-compliance -->
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/// <reference path="../env.d.ts" />
|
||||||
|
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}`
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
|
|||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Project } from "../../project/project"
|
import { Project } from "../../project/project"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
|
import { Session } from "../../session"
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
return c.json(true)
|
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(
|
.get(
|
||||||
"/resource",
|
"/resource",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
|
|||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Installation } from "../installation"
|
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 { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||||
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
@@ -154,6 +156,24 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = z.output<typeof Info>
|
||||||
|
|
||||||
|
export const ProjectInfo = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
worktree: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProjectSummary",
|
||||||
|
})
|
||||||
|
export type ProjectInfo = z.output<typeof ProjectInfo>
|
||||||
|
|
||||||
|
export const GlobalInfo = Info.extend({
|
||||||
|
project: ProjectInfo.nullable(),
|
||||||
|
}).meta({
|
||||||
|
ref: "GlobalSession",
|
||||||
|
})
|
||||||
|
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Created: BusEvent.define(
|
Created: BusEvent.define(
|
||||||
"session.created",
|
"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<string, ProjectInfo>()
|
||||||
|
|
||||||
|
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) => {
|
export const children = fn(Identifier.schema("session"), async (parentID) => {
|
||||||
const project = Instance.project
|
const project = Instance.project
|
||||||
const rows = Database.use((db) =>
|
const rows = Database.use((db) =>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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] <message>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-f, --file <path> 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()
|
||||||
Reference in New Issue
Block a user