#15785 · @tamarazuk · opened Mar 3, 2026 at 3:15 AM UTC · last updated Mar 21, 2026 at 9:49 PM UTC

feat(app): add GitHub PR integration

appfeat
25
+390431131 files

Score breakdown

Impact

6.0

Clarity

3.0

Urgency

2.0

Ease Of Review

1.0

Guidelines

3.0

Readiness

1.0

Size

0.0

Trust

5.0

Traction

2.0

Summary

This PR introduces full GitHub PR integration into OpenCode's web and desktop apps, enabling users to create, merge, view status, and address comments directly. It's a massive feature spanning backend, SDK, and frontend, involving significant code changes. The PR is currently a draft and lacks crucial information like a linked issue and screenshots.

Open in GitHub

Description

Issue for this PR

Closes #

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Adds first-class GitHub PR support to OpenCode’s wonderful web and desktop apps. With this new integration, users will automatically see the status of a pull the number of the pull request, be able to directly link to it in the GitHub UI. On top of these very helpful features. Users can also create a pull request directly from open code. They can see the status of any current CI checks. and they can ask agents to address any of the unresolved review comments.

<details> <summary>AI's version of a "succinct" summary</summary> The feature spans three layers:
  1. Backend (packages/opencode) — New pr.ts and pr-comments.ts modules handle all GitHub interaction via the gh CLI (REST for mutations, GraphQL for review threads). A routes/vcs.ts file exposes REST endpoints for all PR operations. The VCS state manager polls for PR updates and publishes changes over the event bus.

  2. SDK (packages/sdk) — Regenerated TypeScript client and types from the updated OpenAPI spec to expose the new PR/VCS endpoints.

  3. Frontend (packages/app) — PR button in the session header with status pill, dropdown menu (copy link, view CI, merge, mark as ready, address comments). Three new dialogs: create PR (with inline commit-before-push flow and uncommitted-changes guard), merge PR (strategy picker, conflict/checks warnings, post-merge branch deletion), and address review comments (thread selector with bot detection that prefills the agent prompt). PR status badge shown in the sidebar workspace list.

What changed

Backend (packages/opencode)

  • pr.ts — PR create, merge, mark-as-ready, delete-branch operations, plus fetchForBranch (moved from vcs.ts). Throws on unparseable PR numbers, validates branch names.
  • pr-comments.ts — GraphQL-based review thread fetching with typed response interfaces and authorIsBot detection via __typename.
  • routes/vcs.ts — REST endpoints for all VCS/PR operations (create, merge, ready, delete-branch, comments).
  • vcs.ts — GitHub capability detection, default branch fetching, remote branch listing, dirty count tracking, adaptive PR polling (2min active / 4min idle), file watcher integration, refresh mutex to prevent interleaved refreshes.
  • server.ts — Registered VCS routes.

SDK (packages/sdk)

  • Regenerated sdk.gen.ts and types.gen.ts from updated OpenAPI spec.

Frontend (packages/app)

  • pr-button.tsx — PR status button with dropdown menu in session header.
  • dialog-create-pr.tsx — Create PR dialog with commit flow, uncommitted-changes guard, and auto-expanding commit input.
  • dialog-merge-pr.tsx — Merge PR dialog with strategy selection, conflict/checks warnings, post-merge branch deletion.
  • dialog-address-comments.tsx — Review comment selector with bot author detection, circular checkboxes, muted deselected cards, and prompt builder that walks through each comment individually (fix → commit → push → reply with SHA, or skip → reply with rationale). Pre-fills prompt in build mode.
  • session-header.tsx — Added PR button to header.
  • use-session-commands.tsx — PR session commands (create, merge, address comments, mark as ready, copy link, open in browser).
  • sidebar-workspace.tsx — PR status badge matching sidebar unread badge style.
  • global-sync/ — Wired VCS state updates into the sync store.
  • pr-errors.ts — Shared API error parsing utility.
  • pr-style.ts — PR status color helpers.
  • en.ts — All PR-related i18n keys.

UI library (packages/ui)

  • checkbox.tsx — Fixed class prop passthrough to Kobalte root element.
  • dialog.css — Added large dialog size variant.
  • icon.tsx — Added branch icon.

How it works

  • gh CLI is used for all GitHub operations (auth, PR creation, merge via REST API, review comments via GraphQL)
  • VCS state is initialized at startup and kept current through: file watcher (debounced local refreshes on file changes, full refreshes on git ref changes), adaptive polling, and manual refresh after mutations
  • PR status updates flow through the existing event bus → SSE → client sync store pipeline
  • The address-comments dialog fetches unresolved review threads, lets the user select which to address, and prefills the chat prompt with file paths, comment bodies, and gh api reply instructions
  • Bot-authored comments are detected via GraphQL __typename and flagged with authorIsBot

Known Limitations

  • No server-side concurrency protection on mutation routes. The POST endpoints (/pr, /pr/merge, /pr/ready, /pr/delete-branch, /commit) don't have request-level locks. In practice this is mitigated by: UI submitting flags that disable buttons during requests, PR.create being idempotent (returns existing PR), and GitHub's API rejecting duplicate merges. A server-side mutex can be added in a follow-up if needed.
</details>

How did you verify your code works?

  • Manually tested: create PR, merge PR (squash/merge/rebase), mark as ready, delete branch, copy link, view CI status, address review comments flow, uncommitted-changes guard
  • No as any or e: any in new code — proper error typing throughout- tsc --noEmit passes in both packages/opencode and packages/app
  • Full bun turbo typecheck passes

Screenshots / recordings

If this is a UI change, please include a screenshot or recording.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

If you do not follow this template your PR will be automatically rejected.

Linked Issues

None.

Comments

PR comments

anduimagui

@adamdotdevin I found this while prototyping the exact same feature. Strong +1 from me — any chance this could be prioritized?

Changed Files

packages/app/src/components/dialog-address-comments.tsx

+3130
@@ -0,0 +1,313 @@
import { Button } from "@opencode-ai/ui/button"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { createMemo, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { resolveApiErrorMessage } from "@/utils/pr-errors"
import type { ReviewThread } from "@opencode-ai/sdk/v2"
export function AddressCommentsDialog() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
const prompt = usePrompt()
const language = useLanguage()
const vcs = createMemo(() => sync.data.vcs)
const pr = createMemo(() => vcs()?.pr)
const github = createMemo(() => vcs()?.github)
const [store, setStore] = createStore({
loading: true,
err

packages/app/src/components/dialog-create-pr.tsx

+3170
@@ -0,0 +1,317 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { resolveApiErrorMessage } from "@/utils/pr-errors"
export function CreatePrDialog() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const language = useLanguage()
const vcs = createMemo(() => sync.data.vcs)
const branch = createMemo(() => vcs()?.branch ?? "")
const defaultBranch = createMemo(() => vcs()?.defaultBranch ?? "main")
const dirty = createMemo(() => vcs()?.dirty)
const isPushed = createMemo(() => {
const current = branch()
const br

packages/app/src/components/dialog-delete-branch.tsx

+860
@@ -0,0 +1,86 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { resolveApiErrorMessage } from "@/utils/pr-errors"
export function DeleteBranchDialog() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const language = useLanguage()
const pr = createMemo(() => sync.data.vcs?.pr)
const [store, setStore] = createStore({
submitting: false,
})
const handleDelete = async () => {
const p = pr()
if (!p?.headRefName || store.submitting) return
setStore("submitting", true)
try {
await sdk.client.vcs.pr.deleteBranch({
directory: sdk.directory,
prDeleteBranchInput: { branch: p.headRefName },
})
showToast({
variant: "success",

packages/app/src/components/dialog-merge-pr.tsx

+1840
@@ -0,0 +1,184 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { resolveApiErrorMessage } from "@/utils/pr-errors"
type MergeStrategy = "squash" | "merge" | "rebase"
const STRATEGIES: MergeStrategy[] = ["squash", "merge", "rebase"]
export function MergePrDialog() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
const prompt = usePrompt()
const language = useLanguage()
const pr = createMemo(() => sync.data.vcs?.pr)
const hasConflict = createMemo(() => pr()?.mergeable === "CONFLICTIN

packages/app/src/components/pr-button.tsx

+3670
@@ -0,0 +1,367 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { showToast } from "@opencode-ai/ui/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { getPrButtonContainerStyle, getPrButtonDividerStyle, prRequiresAttention } from "@/utils/pr-style"
import { CreatePrDialog } from "./dialog-create-pr"
import { MergePrDialog } from "./dialog-merge-pr"
import { DeleteBranchDialog } from "./dialog-delete-branch"
import { AddressCommentsDialog } from "./dialog-address-comments"
export function PrButton() {
const sync = useSync()
const sdk = useSDK()
const server = useServer()
const platform = u

packages/app/src/components/session/session-header.tsx

+20
@@ -23,6 +23,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { PrButton } from "../pr-button"
import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
@@ -414,6 +415,7 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<PrButton />
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />

packages/app/src/context/global-sync/child-store.ts

+11
@@ -126,7 +126,7 @@ export function createChildStoreManager(input: {
if (!children[directory]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
Persist.workspace(directory, "vcs", ["vcs.v2"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)

packages/app/src/context/global-sync/event-reducer.test.ts

+560
@@ -515,6 +515,62 @@ describe("applyDirectoryEvent", () => {
expect(cacheStore.value).toEqual({ branch: "feature/test" })
})
test("clears vcs fields when update payload sends null markers", () => {
const [store, setStore] = createStore(
baseState({
vcs: {
branch: "feature/test",
defaultBranch: "dev",
branches: ["dev", "feature/test"],
dirty: 2,
pr: {
number: 12,
url: "https://github.com/acme/repo/pull/12",
title: "Test",
state: "OPEN",
headRefName: "feature/test",
baseRefName: "dev",
isDraft: false,
mergeable: "MERGEABLE",
},
github: {
available: true,
authenticated: true,
repo: { owner: "acme", name: "repo" },
},
},
}),
)
const [cacheStore, setCacheStore] = createStore({ value: store.vcs })
applyDirectoryEvent({
event: {
type: "vcs.updated",
properties: {
branch: "feature/next",
defaultBranch: null,
branches: null,
dirty

packages/app/src/context/global-sync/event-reducer.ts

+251
@@ -10,6 +10,7 @@ import type {
Session,
SessionStatus,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
@@ -267,10 +268,33 @@ export function applyDirectoryEvent(input: {
)
break
}
case "vcs.updated": {
const props = event.properties as {
branch?: string
defaultBranch?: string | null
branches?: string[] | null
dirty?: number | null
pr?: VcsInfo["pr"] | null
github?: VcsInfo["github"] | null
}
const next: VcsInfo = {
...input.store.vcs,
...Object.fromEntries(Object.entries(props).filter(([key]) => key !== "branch")),
branch: props.branch ?? input.store.vcs?.branch ?? "",
defaultBranch: props.defaultBranch === null ? undefined : props.defaultBranch ?? input.store.vcs?.defaultBranch,
branches: props.branches === null ? undefined : props.branches ?? input.store.vcs?.branches,
dirty: props.dirty === null ? undefined : props.dirty ?? input.store.vcs?.dirty,
pr: props.pr === null ? undefined : props.pr ?? input.store.vcs

packages/app/src/context/sync.tsx

+33
@@ -117,11 +117,11 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
if (!messages) {
draft.message[input.sessionID] = [input.message]
} else {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}

packages/app/src/i18n/en.ts

+860
@@ -85,6 +85,92 @@ export const dict = {
"command.session.compact.description": "Summarize the session to reduce context size",
"command.session.fork": "Fork from message",
"command.session.fork.description": "Create a new session from a previous message",
"command.category.pr": "Pull Request",
"command.pr.open": "Open Pull Request",
"command.pr.create": "Create Pull Request",
"command.pr.copy": "Copy Pull Request Link",
"command.pr.comments": "Address Review Comments",
"pr.button.open": "PR #{{number}}",
"pr.button.create": "Create PR",
"pr.menu.copy": "Copy PR Link",
"pr.menu.ci": "View CI Status",
"pr.menu.ci.success": "All checks passed",
"pr.menu.ci.failure": "{{failed}} of {{total}} checks failed",
"pr.menu.ci.pending": "{{pending}} check{{plural}} in progress",
"pr.menu.comments": "Address Review Comments",
"pr.menu.comments.one": "Address 1 Comment",
"pr.menu.comments.count": "Address {{count}} Comments",
"pr.menu.comments.none": "No Review Comments",
"pr.menu.status.merged": "Merged",
"pr.menu.status.closed": "Closed",
"pr.create.title": "Create Pull Request",
"pr.create.field.title": "Title",
"pr.cr

packages/app/src/index.css

+110
@@ -1,5 +1,16 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
--pr-color-open: #238636;
--pr-color-open-text: #3fb950;
--pr-color-merged: #8957e5;
--pr-color-merged-text: #a371f7;
--pr-color-closed: #da3633;
--pr-color-closed-text: #f85149;
--pr-color-draft: #768390;
--pr-color-draft-text: #768390;
}
@layer components {
[data-component="getting-started"] {
container-type: inline-size;

packages/app/src/pages/layout/sidebar-workspace.tsx

+262
@@ -12,7 +12,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { type Session, type PrInfo } from "@opencode-ai/sdk/v2/client"
import { getPrPillStyle, prRequiresAttention } from "@/utils/pr-style"
import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@@ -83,13 +84,16 @@ export const WorkspaceDragOverlay = (props: {
)
}
const prPillStyle = getPrPillStyle
const WorkspaceHeader = (props: {
local: Accessor<boolean>
busy: Accessor<boolean>
open: Accessor<boolean>
directory: string
language: ReturnType<typeof useLanguage>
branch: Accessor<string | undefined>
pr: Accessor<PrInfo | undefined>
workspaceValue: Accessor<string>
workspaceEditActive: Accessor<boolean>
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
@@ -130,7 +134,25 @@ const WorkspaceHeader = (props: {
openOnDblClick={false}
/>
</Show>

packages/app/src/pages/session/use-session-commands.tsx

+570
@@ -8,13 +8,17 @@ import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { CreatePrDialog } from "@/components/dialog-create-pr"
import { AddressCommentsDialog } from "@/components/dialog-address-comments"
import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -45,6 +49,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const platform

packages/app/src/utils/pr-errors.ts

+350
@@ -0,0 +1,35 @@
interface ApiError {
code?: string
message?: string
}
export function parseApiError(e: unknown): ApiError {
if (e && typeof e === "object") {
if ("error" in e) {
const inner = (e as { error?: unknown }).error
if (inner && typeof inner === "object") {
return inner as ApiError
}
}
if ("code" in e || "message" in e) {
return e as ApiError
}
}
if (e instanceof Error) return { message: e.message }
if (typeof e === "string") return { message: e }
return {}
}
export function resolveApiErrorMessage(
e: unknown,
fallback: string,
translate?: (key: string) => string,
): string {
const err = parseApiError(e)
if (err.code && translate) {
const key = `pr.error.${err.code.toLowerCase()}`
const translated = translate(key)
if (translated !== key) return translated
}
return err.message ?? fallback
}

packages/app/src/utils/pr-style.ts

+300
@@ -0,0 +1,30 @@
import type { PrInfo } from "@opencode-ai/sdk/v2/client"
export function getPrPillStyle(pr: PrInfo): string {
if (pr.state === "MERGED") return "border-[var(--pr-color-merged)]/40 bg-[var(--pr-color-merged)]/15 text-[var(--pr-color-merged-text)]"
if (pr.state === "CLOSED") return "border-[var(--pr-color-closed)]/40 bg-[var(--pr-color-closed)]/15 text-[var(--pr-color-closed-text)]"
if (pr.isDraft) return "border-[var(--pr-color-draft)]/40 bg-[var(--pr-color-draft)]/15 text-[var(--pr-color-draft-text)]"
return "border-[var(--pr-color-open)]/40 bg-[var(--pr-color-open)]/15 text-[var(--pr-color-open-text)]"
}
export function getPrButtonContainerStyle(pr: PrInfo | undefined): string {
if (!pr) return "border-border-weak-base bg-surface-panel"
if (pr.state === "MERGED") return "border-[var(--pr-color-merged)]/60 bg-[var(--pr-color-merged)]/20"
if (pr.state === "CLOSED") return "border-[var(--pr-color-closed)]/60 bg-[var(--pr-color-closed)]/20"
if (pr.isDraft) return "border-[var(--pr-color-draft)]/60 bg-[var(--pr-color-draft)]/20"
return "border-[var(--pr-color-open)]/60 bg-[var(--pr-color-open)]/20"
}
export function getPrButtonDiv

packages/opencode/src/cli/cmd/pr.ts

+919
@@ -3,6 +3,8 @@ import { cmd } from "./cmd"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
import { git } from "@/util/git"
import { withTimeout } from "@/util/timeout"
import { $ } from "bun"
export const PrCommand = cmd({
command: "pr <number>",
@@ -28,35 +30,23 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
const result = await withTimeout($`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow(), 30_000)
if (result.code !== 0) {
if (result.exitCode !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult = await Process.text(
[
"gh",
"pr

packages/opencode/src/index.ts

+11
@@ -84,7 +84,7 @@ let cli = yargs(hideBin(process.argv))
args: process.argv.slice(2),
})
const marker = path.join(Global.Path.data, "opencode.db")
const marker = Database.Path
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)

packages/opencode/src/project/pr-comments.ts

+2070
@@ -0,0 +1,207 @@
import { $ } from "bun"
import z from "zod"
import { Log } from "@/util/log"
import { withTimeout } from "@/util/timeout"
import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { PR } from "./pr"
const log = Log.create({ service: "pr-comments" })
export namespace PrComments {
export const ReviewComment = z
.object({
id: z.number(),
author: z.string(),
authorIsBot: z.boolean(),
body: z.string(),
path: z.string(),
line: z.number().nullable(),
diffHunk: z.string().optional(),
})
.meta({ ref: "ReviewComment" })
export type ReviewComment = z.infer<typeof ReviewComment>
export const ReviewThread = z
.object({
id: z.string(),
isResolved: z.boolean(),
path: z.string(),
line: z.number().nullable(),
comments: ReviewComment.array(),
})
.meta({ ref: "ReviewThread" })
export type ReviewThread = z.infer<typeof ReviewThread>
export const CommentsResponse = z
.object({
threads: ReviewThread.array(),
promptBlock: z.string(),
unresolvedCount: z.number(),
})
.meta({ ref: "PrCommentsResp

packages/opencode/src/project/pr.ts

+6150
@@ -0,0 +1,615 @@
import { $ } from "bun"
import { generateObject, streamObject, type ModelMessage } from "ai"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "@/session/system"
import { Log } from "@/util/log"
import { withTimeout } from "@/util/timeout"
import { Instance } from "./instance"
import { Vcs } from "./vcs"
const log = Log.create({ service: "pr" })
export namespace PR {
export const ErrorCode = z.enum([
"GH_NOT_INSTALLED",
"GH_NOT_AUTHENTICATED",
"NO_REPO",
"NO_PR",
"CREATE_FAILED",
"MERGE_FAILED",
"DELETE_BRANCH_FAILED",
"COMMENTS_FETCH_FAILED",
"READY_FAILED",
"DRAFT_FAILED",
])
export type ErrorCode = z.infer<typeof ErrorCode>
export const PrError = NamedError.create("PrError", z.object({ code: ErrorCode, message: z.string() }))
export type PrError = InstanceType<typeof PrError>
export const CreateInput = z
.object({
title: z.string().min(1),

packages/opencode/src/project/vcs.ts

+39971
@@ -1,16 +1,52 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { FileWatcher } from "@/file/watcher"
import { Bus } from "@/bus"
import { $ } from "bun"
import z from "zod"
import { Log } from "@/util/log"
import { git } from "@/util/git"
import { withTimeout } from "@/util/timeout"
import { Instance } from "./instance"
import z from "zod"
import { FileWatcher } from "@/file/watcher"
const log = Log.create({ service: "vcs" })
export namespace Vcs {
const log = Log.create({ service: "vcs" })
export const PrInfo = z
.object({
number: z.number(),
url: z.string(),
title: z.string(),
state: z.enum(["OPEN", "CLOSED", "MERGED"]),
headRefName: z.string(),
baseRefName: z.string(),
isDraft: z.boolean(),
mergeable: z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]),
reviewDecision: z.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]).nullable().optional(),
checksState: z.enum(["SUCCESS", "FAILURE"

packages/opencode/src/server/routes/vcs.ts

+2660
@@ -0,0 +1,266 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Vcs } from "../../project/vcs"
import { PR } from "../../project/pr"
import { PrComments } from "../../project/pr-comments"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const CommitInput = z
.object({
message: z.string(),
})
.meta({ ref: "VcsCommitInput" })
export const VcsRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "Get VCS info",
description:
"Retrieve version control system (VCS) information for the current project, including branch, PR, and GitHub capability.",
operationId: "vcs.get",
responses: {
200: {
description: "VCS info",
content: {
"application/json": {
schema: resolver(Vcs.Info),
},
},
},
},
}),
async (c) => {
const data = await Vcs.info()
return c.json(data)
},
)
.get(
"/branches",
describeRoute({
s

packages/opencode/src/server/server.ts

+425
@@ -10,8 +10,8 @@ import { NamedError } from "@opencode-ai/util/error"
import { LSP } from "../lsp"
import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { PR } from "../project/pr"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Auth } from "../auth"
@@ -31,6 +31,7 @@ import { ConfigRoutes } from "./routes/config"
import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { VcsRoutes } from "./routes/vcs"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
@@ -65,6 +66,7 @@ export namespace Server {
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name === "ProviderAuthValidationFailed") status = 400
else if (err.name.startsWith("Worktree")) status = 400
else if (PR.PrError.isInstance(err)) status = err.data.code === "NO_PR" ? 404 :

packages/opencode/src/storage/db.ts

+103
@@ -32,11 +32,18 @@ export namespace Database {
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
const legacy = path.join(Global.Path.data, "opencode.db")
const channel = Installation.CHANNEL
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
const next = path.join(Global.Path.data, `opencode-${safe}.db`)
const preview = Installation.VERSION.startsWith("0.0.0-")
if (Flag.OPENCODE_DISABLE_CHANNEL_DB) return legacy
if (["latest", "beta", "local", "dev"].includes(channel) || preview) {
if (existsSync(legacy) || !existsSync(next)) return legacy
return next
}
if (existsSync(next) || !existsSync(legacy)) return next
return legacy
})
export type Transaction = SQLiteTransaction<"sync", void>

packages/opencode/test/project/vcs.test.ts

+16114
@@ -1,125 +1,27 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher } from "../../src/file/watcher"
import { $ } from "bun"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
import { Vcs } from "../../src/project/vcs"
import { tmpdir } from "../fixture/fixture"
// Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function withVcs(
directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
) {
return withServices(
directory,
Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => {

packages/sdk/js/src/v2/gen/sdk.gen.ts

+37837
@@ -76,6 +76,10 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PrCreateInput,
PrDeleteBranchInput,
PrDraftInput,
PrMergeInput,
ProjectCurrentResponses,
ProjectInitGitResponses,
ProjectListResponses,
@@ -87,6 +91,7 @@ import type {
ProviderOauthAuthorizeResponses,
ProviderOauthCallbackErrors,
ProviderOauthCallbackResponses,
PrReadyInput,
PtyConnectErrors,
PtyConnectResponses,
PtyCreateErrors,
@@ -172,7 +177,24 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
VcsBranchesResponses,
VcsCommitErrors,
VcsCommitInput,
VcsCommitResponses,
VcsGetResponses,
VcsPrCommentsErrors,
VcsPrCommentsResponses,
VcsPrCreateErrors,
VcsPrCreateResponses,
VcsPrDeleteBranchErrors,
VcsPrDeleteBranchResponses,
VcsPrDraftErrors,
VcsPrDraftResponses,
VcsPrGetResponses,
VcsPrMergeErrors,
VcsPrMergeResponses,
VcsPrReadyErrors,
VcsPrReadyResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
WorktreeCreateResponses,
@@ -2877,6 +2899,357 @@ export class Event extends HeyApiClient {
}
}
export class Pr e

packages/sdk/js/src/v2/gen/types.gen.ts

+38523
@@ -889,6 +889,51 @@ export type EventVcsBranchUpdated = {
}
}
export type PrInfo = {
number: number
url: string
title: string
state: "OPEN" | "CLOSED" | "MERGED"
headRefName: string
baseRefName: string
isDraft: boolean
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"
reviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null
checksState?: "SUCCESS" | "FAILURE" | "PENDING" | null
checksUrl?: string
checksSummary?: {
total: number
passed: number
failed: number
pending: number
skipped: number
}
unresolvedCommentCount?: number
branchDeleteFailed?: boolean
}
export type GithubCapability = {
available: boolean
authenticated: boolean
repo?: {
owner: string
name: string
}
host?: string
}
export type EventVcsUpdated = {
type: "vcs.updated"
properties: {
branch?: string
defaultBranch?: string | null
branches?: Array<string> | null
dirty?: number | null
pr?: PrInfo | null
github?: GithubCapability | null
}
}
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
@@ -995,6 +1040,7 @@ export type Ev

packages/ui/src/components/checkbox.tsx

+11
@@ -11,7 +11,7 @@ export interface CheckboxProps extends ParentProps<ComponentProps<typeof Kobalte
export function Checkbox(props: CheckboxProps) {
const [local, others] = splitProps(props, ["children", "class", "label", "hideLabel", "description", "icon"])
return (
<Kobalte {...others} data-component="checkbox">
<Kobalte {...others} class={local.class} data-component="checkbox">
<Kobalte.Input data-slot="checkbox-checkbox-input" />
<Kobalte.Control data-slot="checkbox-checkbox-control">
<Kobalte.Indicator data-slot="checkbox-checkbox-indicator">

packages/ui/src/components/dialog.css

+1010
@@ -114,16 +114,6 @@
}
}
&[data-fit] {
[data-slot="dialog-container"] {
height: auto;
[data-slot="dialog-content"] {
min-height: 0;
}
}
}
&[data-size="large"] [data-slot="dialog-container"] {
width: min(calc(100vw - 32px), 800px);
height: min(calc(100vh - 32px), 600px);
@@ -133,6 +123,16 @@
width: min(calc(100vw - 32px), 960px);
height: min(calc(100vh - 32px), 600px);
}
&[data-fit] {
[data-slot="dialog-container"] {
height: auto;
[data-slot="dialog-content"] {
min-height: 0;
}
}
}
}
[data-component="dialog"][data-transition] [data-slot="dialog-content"] {

packages/ui/src/components/dropdown-menu.css

+10
@@ -47,6 +47,7 @@
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
width: 100%;
&[data-highlighted] {
background: var(--surface-raised-base-hover);

packages/ui/src/components/icon.tsx

+30
@@ -78,6 +78,8 @@ const icons = {
"layout-bottom-full": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linec