diff --git a/packages/app/src/components/dialog-address-comments.tsx b/packages/app/src/components/dialog-address-comments.tsx new file mode 100644 index 000000000000..eb56d8552e40 --- /dev/null +++ b/packages/app/src/components/dialog-address-comments.tsx @@ -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, + error: undefined as string | undefined, + threads: [] as ReviewThread[], + selected: {} as Record, + }) + + onMount(async () => { + try { + const result = await sdk.client.vcs.pr.comments({ directory: sdk.directory }) + const data = result.data + if (!data) { + setStore("error", "No response from server") + setStore("loading", false) + return + } + const selected: Record = {} + for (const thread of data.threads) { + selected[thread.id] = true + } + setStore({ + threads: data.threads, + selected, + loading: false, + }) + } catch (e: unknown) { + if (import.meta.env.DEV) console.error("Fetch comments error:", e) + setStore( + "error", + resolveApiErrorMessage(e, "Failed to fetch review comments", (k) => + language.t(k as Parameters[0]), + ), + ) + setStore("loading", false) + } + }) + + const selectedCount = createMemo(() => Object.values(store.selected).filter(Boolean).length) + const allSelected = createMemo(() => store.threads.length > 0 && selectedCount() === store.threads.length) + + const toggleAll = () => { + const next = !allSelected() + const updated: Record = {} + for (const thread of store.threads) { + updated[thread.id] = next + } + setStore("selected", updated) + } + + const toggleThread = (id: string) => { + setStore("selected", id, !store.selected[id]) + } + + const handleSubmit = () => { + const currentPr = pr() + const repo = github()?.repo + if (!currentPr || !repo) return + + const prNumber = currentPr.number + const owner = repo.owner + const repoName = repo.name + + const selectedThreads = store.threads.filter((t) => store.selected[t.id]) + + let text = `Address the following unresolved review comments on PR #${prNumber}.\n\n` + text += `## Comments to Address\n\n` + + for (const thread of selectedThreads) { + if (thread.comments.length === 0) continue + text += `### File: ${thread.path}${thread.line ? ` (line ${thread.line})` : ""}\n` + for (const comment of thread.comments) { + text += `**@${comment.author}** (comment ID: ${comment.id}):\n\`\`\`\n${comment.body}\n\`\`\`\n` + } + text += "\n" + } + + text += `## Instructions\n\n` + text += `Work through each comment above **one at a time**. The comment ID for each is shown above next to the author. For each comment:\n\n` + text += `1. Read the comment and decide whether to fix it or intentionally skip it\n` + text += `2. **If fixing:**\n` + text += ` - Make the code change\n` + text += ` - Commit with a descriptive message following the repository's commit conventions\n` + text += ` - \`git push\`\n` + text += ` - Get the commit SHA with \`git rev-parse HEAD\`\n` + text += ` - Reply on GitHub referencing the commit:\n` + text += ` \`gh api repos/${owner}/${repoName}/pulls/${prNumber}/comments --method POST -f body="Fixed in : " -F in_reply_to=\`\n` + text += `3. **If skipping:**\n` + text += ` - Reply on GitHub explaining the design rationale:\n` + text += ` \`gh api repos/${owner}/${repoName}/pulls/${prNumber}/comments --method POST -f body="" -F in_reply_to=\`\n` + text += `4. Do NOT merge, rebase, or force-push\n` + + if (local.agent.current()?.name !== "build") { + local.agent.set("build") + } + dialog.close() + requestAnimationFrame(() => { + prompt.set([ + { + type: "text", + content: text, + start: 0, + end: text.length, + }, + ]) + }) + } + + return ( + +
+ + +
+ } + > + +
+ + {language.t("pr.comments.error.title")} + {store.error} +
+
+ + 0} + fallback={ +
+ + {language.t("pr.comments.none")} +
+ } + > + {/* Header with count and select all toggle */} +
+ + {store.threads.length === 1 + ? language.t("pr.comments.count.one") + : language.t("pr.comments.count", { count: String(store.threads.length) })} + + +
+ + {/* Comment thread list */} +
+ + {(thread) => { + const firstComment = () => thread.comments[0] + const replies = () => thread.comments.slice(1) + const isChecked = () => !!store.selected[thread.id] + + return ( +
toggleThread(thread.id)} + > +
+ +   + +
+
+ {/* File path + line */} +
+ + {thread.path} + + + : + {thread.line} + + +
+ + {/* First comment (the review comment) */} + + {(comment) => { + const isBot = comment().authorIsBot + return ( +
+
+
+ @{comment().author} + {isBot && ( + + Bot + + )} +
+ {(() => { + const g = github() + const p = pr() + const href = + g?.repo && p?.url ? `${p.url}#discussion_r${comment().id}` : undefined + return ( + + {(url) => ( + e.stopPropagation()} + class="text-icon-weaker hover:text-text-weak transition-colors shrink-0 cursor-pointer" + title="View on GitHub" + > + + + )} + + ) + })()} +
+

+ {comment().body} +

+
+ ) + }} +
+ + {/* Reply count */} + 0}> +
+ + + {replies().length === 1 + ? language.t("pr.comments.replies.one") + : language.t("pr.comments.replies.count", { count: String(replies().length) })} + +
+
+
+
+ ) + }} +
+
+
+
+ + + {/* Footer */} +
+
+ 0 && !store.error}> + + {language.t("pr.comments.selected", { + selected: String(selectedCount()), + total: String(store.threads.length), + })} + + +
+
+ + 0 && !store.error}> + + +
+
+ +
+ ) +} diff --git a/packages/app/src/components/dialog-create-pr.tsx b/packages/app/src/components/dialog-create-pr.tsx new file mode 100644 index 000000000000..127afc1ab554 --- /dev/null +++ b/packages/app/src/components/dialog-create-pr.tsx @@ -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 branches = vcs()?.branches ?? [] + return branches.includes(current) + }) + + const branchTitle = createMemo(() => { + const b = branch() + if (!b) return "" + return b.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) + }) + + const baseOptions = createMemo(() => { + const remote = vcs()?.branches + if (remote && remote.length > 0) return remote + return [defaultBranch()] + }) + + const [store, setStore] = createStore({ + title: branchTitle(), + body: "", + base: defaultBranch(), + draft: false, + draftLoading: false, + submitting: false, + committing: false, + commitMessage: branchTitle(), + showCommitInput: false, + error: undefined as string | undefined, + }) + + const [titleEdited, setTitleEdited] = createSignal(false) + const [bodyEdited, setBodyEdited] = createSignal(false) + const [baseEdited, setBaseEdited] = createSignal(false) + let draftRequest = 0 + + createEffect(() => { + if ((dirty() ?? 0) > 0) { + setStore("showCommitInput", true) + } + }) + + createEffect(() => { + if (store.showCommitInput && !titleEdited()) { + setStore("title", store.commitMessage) + } + }) + + createEffect(() => { + const t = branchTitle() + if (t && !titleEdited() && !store.showCommitInput) { + setStore("title", t) + setStore("commitMessage", t) + } + }) + + createEffect(() => { + const b = defaultBranch() + if (b && !baseEdited()) { + setStore("base", b) + } + }) + + createEffect(() => { + const currentBranch = branch() + const base = store.base || defaultBranch() + const dirtyCount = dirty() ?? 0 + if (!currentBranch || !base) return + if (titleEdited() && bodyEdited()) return + + const request = ++draftRequest + setStore("draftLoading", true) + + sdk.client.vcs.pr + .draft({ + directory: sdk.directory, + prDraftInput: { base }, + }) + .then((result) => { + if (request !== draftRequest) return + const draft = result.data + if (!draft) return + if (!titleEdited()) setStore("title", draft.title) + if (!bodyEdited()) setStore("body", draft.body) + }) + .catch(() => {}) + .finally(() => { + if (request !== draftRequest) return + setStore("draftLoading", false) + }) + }) + + const handleCommit = async () => { + if (!store.commitMessage.trim() || store.committing) return + setStore("committing", true) + setStore("error", undefined) + + try { + await sdk.client.vcs.commit({ + directory: sdk.directory, + vcsCommitInput: { + message: store.commitMessage.trim(), + }, + }) + + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("pr.commit.success"), + }) + setStore("showCommitInput", false) + } catch (e: unknown) { + const err = (e as { error?: { message?: string } })?.error ?? e + setStore("error", (err as { message?: string })?.message ?? "Commit failed") + } finally { + setStore("committing", false) + } + } + + const handleSubmit = async () => { + if (!store.title.trim() || store.submitting) return + setStore("submitting", true) + setStore("error", undefined) + + try { + await sdk.client.vcs.pr.create({ + directory: sdk.directory, + prCreateInput: { + title: store.title.trim(), + body: store.body.trim() || store.title.trim(), + base: store.base || undefined, + draft: store.draft, + }, + }) + + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("pr.toast.created"), + }) + dialog.close() + } catch (e: unknown) { + if (import.meta.env.DEV) console.error("PR creation error:", e) + setStore( + "error", + resolveApiErrorMessage(e, "Failed to create PR", (k) => language.t(k as Parameters[0])), + ) + } finally { + setStore("submitting", false) + } + } + + return ( + +
+ {/* Branch flow indicator */} +
+ + {branch()} + + {store.base || defaultBranch()} +
+ + +
+ + {language.t("pr.create.generating")} +
+
+ + 0}> +
+
+ + + {language.t("pr.commit.warning", { count: String(dirty()) })} + + + + +
+ +
+ setStore("commitMessage", e.currentTarget.value)} + placeholder={language.t("pr.commit.placeholder")} + class="flex-1" + autofocus + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleCommit() + } + }} + /> + +
+
+
+
+
+ + { + setTitleEdited(true) + setStore("title", e.currentTarget.value) + }} + autofocus + /> +
+
+ + { + setBodyEdited(true) + setStore("body", e.currentTarget.value) + }} + placeholder={language.t("pr.create.field.body.placeholder")} + style={{ "min-height": "80px" }} + /> +
+
+ + setStore("strategy", (v as MergeStrategy) ?? "squash")} + variant="secondary" + triggerVariant="settings" + triggerStyle={{ + width: "100%", + "justify-content": "space-between", + background: "var(--input-base)", + border: "1px solid var(--border-weak-base)", + "border-radius": "var(--radius-md)", + }} + > + {(item) => {item ? strategyLabel(item) : ""}} + +
+ + {/* Delete branch checkbox */} + setStore("deleteBranch", checked)}> + {language.t("pr.merge.delete_branch")} + + + {/* Error display */} + +
+ + {store.error} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/pr-button.tsx b/packages/app/src/components/pr-button.tsx new file mode 100644 index 000000000000..86e6b0e32733 --- /dev/null +++ b/packages/app/src/components/pr-button.tsx @@ -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 = usePlatform() + const language = useLanguage() + const dialog = useDialog() + + const [menu, setMenu] = createStore({ open: false }) + const [readyLoading, setReadyLoading] = createSignal(false) + + const vcs = createMemo(() => sync.data.vcs) + const pr = createMemo(() => vcs()?.pr) + const github = createMemo(() => vcs()?.github) + const defaultBranch = createMemo(() => vcs()?.defaultBranch) + const branch = createMemo(() => vcs()?.branch) + + const isOnDefaultBranch = createMemo(() => { + const b = branch() + const db = defaultBranch() + if (!b || !db) return false + return b === db + }) + + const hidden = createMemo(() => { + if (!vcs()) return true + if (!github()?.available) return true + if (isOnDefaultBranch()) return true + return false + }) + + const canMutate = createMemo(() => { + return server.isLocal() && github()?.authenticated + }) + + const tooltipText = createMemo(() => { + if (!github()?.authenticated) return language.t("pr.tooltip.not_authenticated") + if (!server.isLocal()) return language.t("pr.tooltip.not_local") + return undefined + }) + + const isOpen = createMemo(() => { + const p = pr() + return p?.state === "OPEN" + }) + + const isMerged = createMemo(() => pr()?.state === "MERGED") + const isClosed = createMemo(() => pr()?.state === "CLOSED") + + const remoteBranchExists = createMemo(() => { + const branchName = pr()?.headRefName + const branches = vcs()?.branches + if (!branchName || !branches) return false + return branches.includes(branchName) + }) + + const containerStyle = createMemo(() => getPrButtonContainerStyle(pr())) + const dividerStyle = createMemo(() => getPrButtonDividerStyle(pr())) + + const ciLabel = createMemo(() => { + const p = pr() + if (!p?.checksState) return undefined + const summary = p.checksSummary + if (p.checksState === "SUCCESS") return language.t("pr.menu.ci.success") + if (p.checksState === "FAILURE" && summary) { + return language.t("pr.menu.ci.failure", { + failed: String(summary.failed), + total: String(summary.total), + }) + } + if (p.checksState === "PENDING" && summary) { + return language.t("pr.menu.ci.pending", { + pending: String(summary.pending), + plural: summary.pending === 1 ? "" : "s", + }) + } + return language.t("pr.menu.ci") + }) + + const ciIconName = createMemo(() => { + const p = pr() + if (p?.checksState === "SUCCESS") return "circle-check" as const + if (p?.checksState === "FAILURE") return "circle-x" as const + return "loader" as const + }) + + const ciIconColor = createMemo(() => { + const p = pr() + if (p?.checksState === "SUCCESS") return "text-icon-success-base" + if (p?.checksState === "FAILURE") return "text-icon-critical-base" + return "text-icon-warning-base" + }) + + const ciIconSpin = createMemo(() => pr()?.checksState === "PENDING") + + const commentLabel = createMemo(() => { + const p = pr() + if (!p) return language.t("pr.menu.comments") + if (p.unresolvedCommentCount === undefined) return language.t("pr.menu.comments") + if (p.unresolvedCommentCount === 0) return language.t("pr.menu.comments.none") + if (p.unresolvedCommentCount === 1) return language.t("pr.menu.comments.one") + return language.t("pr.menu.comments.count", { count: String(p.unresolvedCommentCount) }) + }) + + const openPr = () => { + const p = pr() + if (!p?.url) return + platform.openLink(p.url) + } + + const copyLink = () => { + const p = pr() + if (!p?.url) return + navigator.clipboard + .writeText(p.url) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("pr.toast.copied"), + description: p.url, + }) + }) + .catch(() => { + // Clipboard write can fail in non-secure contexts; not actionable + }) + } + + const viewCI = () => { + const p = pr() + if (!p?.checksUrl) return + platform.openLink(p.checksUrl) + } + + const openCreateDialog = () => { + dialog.show(() => ) + } + + const openAddressDialog = () => { + dialog.show(() => ) + } + + const openMergeDialog = () => { + dialog.show(() => ) + } + + const openDeleteDialog = () => { + dialog.show(() => ) + } + + const handleMarkReady = () => { + setReadyLoading(true) + sdk.client.vcs.pr + .ready({ directory: sdk.directory }) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("pr.toast.ready"), + }) + }) + .catch(() => { + showToast({ + variant: "error", + icon: "circle-x", + title: language.t("pr.error.ready_failed"), + }) + }) + .finally(() => { + setReadyLoading(false) + }) + } + + return ( + +
+
+ +
+ +
+ + + + } + > + {(currentPr) => ( + <> + +
+ setMenu("open", open)} + > + + } + > + + + + + + {/* Copy link — always visible */} + { + setMenu("open", false) + copyLink() + }} + > + + {language.t("pr.menu.copy")} + + + {/* CI status — shown when checks exist */} + + + { + setMenu("open", false) + viewCI() + }} + > + + {ciLabel()} + + + + {/* Mark as ready — only for draft PRs */} + + + + + + {language.t("pr.menu.ready")} + + + + + {/* Merge — only for open, non-draft PRs */} + + + + { + setMenu("open", false) + openMergeDialog() + }} + > + + {language.t("pr.menu.merge")} + + + + + {/* Address comments — only for open/draft PRs */} + + + + { + setMenu("open", false) + openAddressDialog() + }} + > + + {commentLabel()} + + + + + {/* Status info for merged/closed */} + + + + + + {isMerged() ? language.t("pr.menu.status.merged") : language.t("pr.menu.status.closed")} + + + + + {/* Delete branch — only for merged PRs with branch still existing */} + + { + setMenu("open", false) + openDeleteDialog() + }} + > + + {language.t("pr.menu.delete_branch")} + + + + + + + )} + +
+
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b32340580..4fe63e4dc8cd 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -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() {
+
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 70668350ec20..28309ee74c7b 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -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 }), ), ) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index cf2da135cbbc..e921ab5ca63c 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -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: null, + pr: null, + github: null, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + vcsCache: { + store: cacheStore, + setStore: setCacheStore, + ready: () => true, + }, + }) + + expect(store.vcs).toEqual({ branch: "feature/next" }) + expect(cacheStore.value).toEqual({ branch: "feature/next" }) + }) + test("routes disposal and lsp events to side-effect handlers", () => { const [store, setStore] = createStore(baseState()) const pushes: string[] = [] diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b8eda0573f7b..ec459e771977 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -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?.pr, + github: props.github === null ? undefined : props.github ?? input.store.vcs?.github, + } + input.setStore("vcs", next) + if (input.vcsCache) input.vcsCache.setStore("value", next) + break + } case "vcs.branch.updated": { const props = event.properties as { branch: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0f2008723456..372b2ee44f54 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -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) } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8efd9d3bc9f0..d3c39373a27a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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.create.field.body": "Description", + "pr.create.field.body.placeholder": "Leave empty to use title as description", + "pr.create.field.base": "Base Branch", + "pr.create.field.draft": "Create as Draft", + "pr.create.generating": "Generating draft...", + "pr.create.submit": "Create", + "pr.create.submitPush": "Push & Create", + "pr.create.submitting": "Creating...", + "pr.commit.warning": "{{count}} uncommitted changes", + "pr.commit.action": "Commit changes", + "pr.commit.placeholder": "Commit message", + "pr.commit.submit": "Commit", + "pr.commit.committing": "Committing...", + "pr.commit.success": "Changes committed", + "pr.commit.requiredHint": "Commit your changes before creating a PR", + "pr.comments.title": "Address Review Comments", + "pr.comments.count": "{{count}} unresolved comments", + "pr.comments.count.one": "1 unresolved comment", + "pr.comments.selected": "{{selected}} of {{total}} selected", + "pr.comments.none": "No unresolved comments", + "pr.comments.error.title": "Unable to fetch comments", + "pr.comments.instructions": "Additional instructions", + "pr.comments.instructions.placeholder": "Any additional instructions for the agent...", + "pr.comments.submit": "Prefill Prompt", + "pr.comments.submitWithSwitch": "Prefill Prompt & Switch to Build", + "pr.comments.replies.one": "1 reply", + "pr.comments.replies.count": "{{count}} replies", + "pr.comments.select.all": "Select all", + "pr.comments.select.none": "Deselect all", + "pr.toast.copied": "PR link copied", + "pr.toast.created": "Pull request created", + "pr.error.gh_not_installed": "GitHub CLI (gh) is not installed", + "pr.error.gh_not_authenticated": "Run `gh auth login` to authenticate", + "pr.error.upstream_missing": "Push your branch first", + "pr.error.create_failed": "Failed to create pull request", + "pr.error.no_pr": "No pull request found", + "pr.error.comments_fetch_failed": "Failed to fetch review comments", + "pr.menu.merge": "Merge Pull Request", + "pr.menu.ready": "Mark as ready for review", + "pr.menu.delete_branch": "Delete Branch", + "pr.merge.title": "Merge Pull Request", + "pr.merge.strategy": "Merge Strategy", + "pr.merge.strategy.squash": "Squash and merge", + "pr.merge.strategy.merge": "Create a merge commit", + "pr.merge.strategy.rebase": "Rebase and merge", + "pr.merge.delete_branch": "Delete branch after merge", + "pr.merge.submit": "Merge", + "pr.merge.submitting": "Merging...", + "pr.merge.conflict": "This branch has conflicts that must be resolved", + "pr.merge.conflict.fix": "Ask agent to fix conflicts", + "pr.merge.checks_failing": "Some checks are failing", + "pr.toast.merged": "Pull request merged", + "pr.toast.ready": "Pull request marked as ready for review", + "pr.toast.branch_deleted": "Branch deleted", + "pr.error.merge_failed": "Failed to merge pull request", + "pr.error.ready_failed": "Failed to mark PR as ready", + "pr.error.delete_branch_failed": "Failed to delete branch", + "pr.delete.title": "Delete Branch", + "pr.delete.warning": + "This will permanently delete the remote branch. It can be restored from the pull request page on GitHub.", + "pr.delete.submit": "Delete Branch", + "pr.delete.submitting": "Deleting...", + "pr.tooltip.not_authenticated": "Run `gh auth login` to enable", + "pr.tooltip.not_local": "PR actions require local server", + "command.session.share": "Share session", "command.session.share.description": "Share this session and copy the URL to clipboard", "command.session.unshare": "Unshare session", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 9e231e2d2858..7147944ab8bf 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 3bf00ea424d6..bc8244d30c9a 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -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,6 +84,8 @@ export const WorkspaceDragOverlay = (props: { ) } +const prPillStyle = getPrPillStyle + const WorkspaceHeader = (props: { local: Accessor busy: Accessor @@ -90,6 +93,7 @@ const WorkspaceHeader = (props: { directory: string language: ReturnType branch: Accessor + pr: Accessor workspaceValue: Accessor workspaceEditActive: Accessor InlineEditor: WorkspaceSidebarContext["InlineEditor"] @@ -130,7 +134,25 @@ const WorkspaceHeader = (props: { openOnDblClick={false} /> -
+ + {(currentPr) => { + const pillStyle = () => prPillStyle(currentPr()) + const requiresAttention = () => prRequiresAttention(currentPr()) + return ( +
+ + PR #{currentPr().number} + + +
+ +
+ ) + }} +
+
@@ -353,6 +375,7 @@ export const SortableWorkspace = (props: { directory={props.directory} language={language} branch={() => workspaceStore.vcs?.branch} + pr={() => workspaceStore.vcs?.pr} workspaceValue={workspaceValue} workspaceEditActive={workspaceEditActive} InlineEditor={props.ctx.InlineEditor} @@ -382,6 +405,7 @@ export const SortableWorkspace = (props: { "opacity-50 pointer-events-none": busy(), }} > +
{ const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() + const platform = usePlatform() + const server = useServer() const sync = useSync() const terminal = useTerminal() const layout = useLayout() @@ -126,6 +132,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const mcpCommand = withCategory(language.t("command.category.mcp")) const agentCommand = withCategory(language.t("command.category.agent")) const permissionsCommand = withCategory(language.t("command.category.permissions")) + const prCommand = withCategory(language.t("command.category.pr")) const isAutoAcceptActive = () => { const sessionID = params.id @@ -489,6 +496,56 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => ), }), + ...(function () { + const vcs = sync.data.vcs + if (!vcs?.github?.available) return [] + + const hasPr = !!vcs.pr + const canMutate = server.isLocal() && vcs.github.authenticated + + if (hasPr) { + return [ + prCommand({ + id: "pr.open", + title: language.t("command.pr.open"), + onSelect: () => { + if (vcs.pr?.url) platform.openLink(vcs.pr.url) + }, + }), + prCommand({ + id: "pr.copy", + title: language.t("command.pr.copy"), + onSelect: () => { + if (!vcs.pr?.url) return + navigator.clipboard.writeText(vcs.pr.url).then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("pr.toast.copied"), + }) + }) + }, + }), + prCommand({ + id: "pr.comments", + title: language.t("command.pr.comments"), + disabled: !canMutate, + onSelect: () => dialog.show(() => ), + }), + ] + } + + if (vcs.branch === vcs.defaultBranch) return [] + + return [ + prCommand({ + id: "pr.create", + title: language.t("command.pr.create"), + disabled: !canMutate, + onSelect: () => dialog.show(() => ), + }), + ] + })(), ...share, ] }) diff --git a/packages/app/src/utils/pr-errors.ts b/packages/app/src/utils/pr-errors.ts new file mode 100644 index 000000000000..f1df2787d830 --- /dev/null +++ b/packages/app/src/utils/pr-errors.ts @@ -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 +} diff --git a/packages/app/src/utils/pr-style.ts b/packages/app/src/utils/pr-style.ts new file mode 100644 index 000000000000..bd6a0194edfc --- /dev/null +++ b/packages/app/src/utils/pr-style.ts @@ -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 getPrButtonDividerStyle(pr: PrInfo | undefined): string { + if (!pr) return "bg-border-weak-base" + if (pr.state === "MERGED") return "bg-[var(--pr-color-merged)]/60" + if (pr.state === "CLOSED") return "bg-[var(--pr-color-closed)]/60" + if (pr.isDraft) return "bg-[var(--pr-color-draft)]/60" + return "bg-[var(--pr-color-open)]/60" +} + +export function prRequiresAttention(pr: PrInfo | undefined): boolean { + if (!pr) return false + if (pr.state === "MERGED" || pr.state === "CLOSED") return false + return (pr.unresolvedCommentCount ?? 0) > 0 || pr.checksState === "FAILURE" +} diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 8826fe343ea0..532512943da0 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -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 ", @@ -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", - "view", - `${prNumber}`, - "--json", - "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", - ], - { nothrow: true }, + const prInfoResult = await withTimeout( + $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow(), + 30_000, ) let sessionId: string | undefined - if (prInfoResult.code === 0) { - const prInfoText = prInfoResult.text + if (prInfoResult.exitCode === 0) { + const prInfoText = prInfoResult.stdout.toString() if (prInfoText.trim()) { const prInfo = JSON.parse(prInfoText) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index b3d1db7eb0cb..beff492f8b42 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -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) diff --git a/packages/opencode/src/project/pr-comments.ts b/packages/opencode/src/project/pr-comments.ts new file mode 100644 index 000000000000..1e19e26ff636 --- /dev/null +++ b/packages/opencode/src/project/pr-comments.ts @@ -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 + + 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 + + export const CommentsResponse = z + .object({ + threads: ReviewThread.array(), + promptBlock: z.string(), + unresolvedCount: z.number(), + }) + .meta({ ref: "PrCommentsResponse" }) + export type CommentsResponse = z.infer + + interface GqlReviewThreadsResponse { + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string } + nodes?: Array<{ + id: string + isResolved: boolean + path: string + line: number | null + comments: { + nodes: Array<{ + databaseId: number + author: { login: string; __typename?: string } + body: string + path: string + line: number | null + diffHunk?: string + }> + } + }> + } + } + } + } + errors?: Array<{ message?: string }> + } + + function buildQuery(): string { + return `query($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $prNumber) { reviewThreads(first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id isResolved path line comments(first: 50) { nodes { databaseId author { login __typename } body path line: originalLine diffHunk } } } } } } }` + } + + export async function fetch(): Promise { + const info = await Vcs.info() + if (!info.pr) { + throw new PR.PrError({ code: "NO_PR", message: "No pull request found for current branch" }) + } + if (!info.github?.authenticated || !info.github.repo) { + throw new PR.PrError({ code: "GH_NOT_AUTHENTICATED", message: "GitHub CLI is not authenticated" }) + } + + const { owner, name } = info.github.repo + const prNumber = info.pr.number + const cwd = Instance.worktree + + const allThreads: ReviewThread[] = [] + let cursor: string | null = null + const MAX_PAGES = 10 + let page = 0 + + do { + if (++page > MAX_PAGES) { + log.warn("pr-comments: max pages reached, truncating") + break + } + const query = buildQuery() + const args = [ + "gh", + "api", + "graphql", + "-f", + `query=${query}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `prNumber=${prNumber}`, + ] + if (cursor) { + args.push("-f", `cursor=${cursor}`) + } + const cmd = await withTimeout($`${args}`.quiet().nothrow().cwd(cwd), 30_000) + + const stdout = cmd.stdout.toString() + const stderr = cmd.stderr.toString() + + if (cmd.exitCode !== 0) { + log.error("gh api graphql failed", { exitCode: cmd.exitCode, stderr }) + throw new PR.PrError({ + code: "COMMENTS_FETCH_FAILED", + message: stderr.trim() || `gh api graphql exited with code ${cmd.exitCode}`, + }) + } + + if (!stdout.trim()) { + log.error("gh api graphql returned empty response") + throw new PR.PrError({ code: "COMMENTS_FETCH_FAILED", message: "GitHub API returned empty response" }) + } + + let parsed: Record + try { + parsed = JSON.parse(stdout) + } catch { + log.error("gh api graphql returned invalid JSON", { stdout: stdout.slice(0, 200) }) + throw new PR.PrError({ code: "COMMENTS_FETCH_FAILED", message: "GitHub API returned invalid response" }) + } + + const response = parsed as GqlReviewThreadsResponse + + if (response.errors) { + const msg = response.errors.map((e) => e.message).join("; ") + log.error("GraphQL errors", { errors: response.errors }) + throw new PR.PrError({ code: "COMMENTS_FETCH_FAILED", message: msg || "GraphQL query failed" }) + } + + const threads = response.data?.repository?.pullRequest?.reviewThreads + if (!threads) break + + for (const node of threads.nodes ?? []) { + allThreads.push({ + id: node.id, + isResolved: node.isResolved, + path: node.path ?? "", + line: node.line, + comments: (node.comments.nodes ?? []).map((c) => ({ + id: c.databaseId, + author: c.author?.login ?? "unknown", + authorIsBot: c.author?.__typename === "Bot", + body: c.body ?? "", + path: c.path ?? "", + line: c.line ?? null, + diffHunk: c.diffHunk, + })), + }) + } + + cursor = threads.pageInfo?.hasNextPage ? (threads.pageInfo.endCursor ?? null) : null + } while (cursor) + + const unresolved = allThreads.filter((t) => !t.isResolved) + const promptBlock = formatPromptBlock(unresolved) + + return { + threads: unresolved, + promptBlock, + unresolvedCount: unresolved.length, + } + } + + function formatPromptBlock(threads: ReviewThread[]): string { + if (threads.length === 0) return "No unresolved review comments." + + const blocks = threads.map((thread) => { + if (thread.comments.length === 0) return "" + + const lines = [`### File: ${thread.path}${thread.line ? ` (line ${thread.line})` : ""}`] + for (const comment of thread.comments) { + lines.push(`**@${comment.author}** (comment ID: ${comment.id}): ${comment.body}`) + if (comment.diffHunk) { + const contextLine = comment.diffHunk.split("\n").pop() + if (contextLine) { + lines.push(`> Code context: \`${contextLine.replace(/^[+-]/, "").trim()}\``) + } + } + } + return lines.join("\n") + }) + + return blocks.filter(Boolean).join("\n\n") + } +} diff --git a/packages/opencode/src/project/pr.ts b/packages/opencode/src/project/pr.ts new file mode 100644 index 000000000000..e69a37ac5b42 --- /dev/null +++ b/packages/opencode/src/project/pr.ts @@ -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 + + export const PrError = NamedError.create("PrError", z.object({ code: ErrorCode, message: z.string() })) + export type PrError = InstanceType + + export const CreateInput = z + .object({ + title: z.string().min(1), + body: z.string(), + base: z.string().optional(), + draft: z.boolean().optional(), + }) + .meta({ ref: "PrCreateInput" }) + export type CreateInput = z.infer + + export const MergeInput = z + .object({ + strategy: z.enum(["merge", "squash", "rebase"]).optional(), + deleteBranch: z.boolean().optional(), + }) + .meta({ ref: "PrMergeInput" }) + export type MergeInput = z.infer + + export const DeleteBranchInput = z + .object({ + branch: z.string().regex(/^(?!.*\.\.)[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/, "Invalid branch name"), + }) + .meta({ ref: "PrDeleteBranchInput" }) + export type DeleteBranchInput = z.infer + + export const ReadyInput = z.object({}).meta({ ref: "PrReadyInput" }) + export type ReadyInput = z.infer + + export const DraftInput = z + .object({ + base: z.string().optional(), + }) + .meta({ ref: "PrDraftInput" }) + export type DraftInput = z.infer + + export const DraftOutput = z + .object({ + title: z.string().min(1), + body: z.string().min(1), + }) + .meta({ ref: "PrDraftOutput" }) + export type DraftOutput = z.infer + + export const PrErrorResponse = PrError.Schema + + const DraftSchema = z.object({ + title: z.string(), + body: z.string(), + }) + + function fallbackTitle(branch: string) { + return branch + .split("/") + .at(-1) + ?.replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim() || "Update branch" + } + + function fallbackBody(title: string) { + return `## Summary +- ${title} + +## Testing +- Not run` + } + + function cleanDraftTitle(title: string, branch: string) { + const line = title + .replace(/[\s\S]*?<\/think>\s*/g, "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + const next = line?.replace(/[.]+$/g, "").trim() + if (!next) return fallbackTitle(branch) + return next.length > 100 ? next.slice(0, 97).trimEnd() + "..." : next + } + + function cleanDraftBody(body: string, title: string) { + const next = body.replace(/[\s\S]*?<\/think>\s*/g, "").trim() + if (!next) return fallbackBody(title) + return next + } + + async function selectDraftModel() { + const defaultModel = await Provider.defaultModel() + return ( + (await Provider.getSmallModel(defaultModel.providerID).catch(() => undefined)) ?? + (await Provider.getModel(defaultModel.providerID, defaultModel.modelID)) + ) + } + + async function generateDraftObject(input: { branch: string; base: string; commits: string; diffStat: string; diffPatch: string }) { + const model = await selectDraftModel() + const language = await Provider.getLanguage(model) + const system = [ + [ + "You write GitHub pull request drafts.", + "Return a JSON object with keys: title, body.", + "Rules:", + "- title must be concise, specific, and written for a pull request", + "- body must be markdown", + "- body must include headings '## Summary' and '## Testing'", + "- under Summary, use short bullet points", + "- under Testing, use bullet points with concrete checks or 'Not run'", + "- do not invent requirements, screenshots, or test results", + ].join("\n"), + ] + await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) + + const prompt = [ + `Head branch: ${input.branch}`, + `Base branch: ${input.base}`, + "", + "Commits:", + input.commits || "(none)", + "", + "Diff stat:", + input.diffStat || "(none)", + "", + "Diff patch:", + input.diffPatch || "(none)", + ].join("\n") + + const messages: ModelMessage[] = [ + ...system.map((content) => ({ + role: "system" as const, + content, + })), + { + role: "user", + content: prompt, + }, + ] + + const isCodex = model.providerID === "openai" && (await Auth.get(model.providerID))?.type === "oauth" + const params = { + model: language, + schema: DraftSchema, + temperature: 0.2, + messages, + } satisfies Parameters[0] + + if (isCodex) { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(model, { + instructions: system.join("\n"), + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return await result.object + } + + const result = await generateObject(params) + return result.object + } + + async function fetchUnresolvedCommentCount( + owner: string, + name: string, + prNumber: number, + ): Promise { + const cwd = Instance.worktree + const query = `query($owner: String!, $name: String!, $prNumber: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $prNumber) { reviewThreads(first: 100) { nodes { isResolved } } } } }` + const result = await withTimeout( + $`gh api graphql -f query=${query} -f owner=${owner} -f name=${name} -F prNumber=${prNumber}` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => ""), + 30_000, + ) + try { + const parsed = JSON.parse(result) + const threads = parsed?.data?.repository?.pullRequest?.reviewThreads?.nodes + if (!Array.isArray(threads)) return undefined + return threads.filter((t: { isResolved: boolean }) => !t.isResolved).length + } catch (e) { + log.warn("fetchUnresolvedCommentCount failed", { error: e }) + return undefined + } + } + + export async function fetchForBranch(repo?: { owner: string; name: string }): Promise { + const cwd = Instance.worktree + try { + const cmd = await withTimeout( + $`gh pr view --json number,url,title,state,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup` + .quiet() + .nothrow() + .cwd(cwd), + 30_000, + ) + if (cmd.exitCode !== 0) { + log.warn("gh pr view failed", { stderr: cmd.stderr.toString().trim() }) + return undefined + } + const result = cmd.stdout.toString().trim() + if (!result) { + return undefined + } + const parsed = JSON.parse(result) + if (!parsed.number) return undefined + + let checksState: "SUCCESS" | "FAILURE" | "PENDING" | null = null + let checksUrl: string | undefined + let checksSummary: { total: number; passed: number; failed: number; pending: number; skipped: number } | undefined + if (parsed.statusCheckRollup && Array.isArray(parsed.statusCheckRollup)) { + const checks = parsed.statusCheckRollup as Array<{ + __typename?: string + conclusion?: string + status?: string + state?: string + detailsUrl?: string + }> + if (checks.length > 0) { + checksUrl = parsed.url ? `${parsed.url}/checks` : checks[0]?.detailsUrl?.replace(/\/runs\/.*/, "") + // StatusContext entries (e.g. CodeRabbit) use `state` instead of `conclusion`/`status` + const isSuccess = (c: (typeof checks)[0]) => + c.conclusion === "SUCCESS" || c.conclusion === "success" || c.state === "SUCCESS" || c.state === "success" + const isFailure = (c: (typeof checks)[0]) => + c.conclusion === "FAILURE" || + c.conclusion === "failure" || + c.state === "FAILURE" || + c.state === "failure" || + c.state === "ERROR" || + c.state === "error" + const isSkipped = (c: (typeof checks)[0]) => + c.conclusion === "SKIPPED" || + c.conclusion === "skipped" || + c.conclusion === "NEUTRAL" || + c.conclusion === "neutral" + const failed = checks.filter(isFailure).length + const passed = checks.filter(isSuccess).length + const skipped = checks.filter(isSkipped).length + const total = checks.length + const pending = total - passed - failed - skipped + checksSummary = { total, passed, failed, pending, skipped } + if (failed > 0) checksState = "FAILURE" + else if (pending === 0) checksState = "SUCCESS" + else checksState = "PENDING" + } + } + + const stateMap: Record = { + OPEN: "OPEN", + CLOSED: "CLOSED", + MERGED: "MERGED", + } + + const mergeableMap: Record = { + MERGEABLE: "MERGEABLE", + CONFLICTING: "CONFLICTING", + UNKNOWN: "UNKNOWN", + } + + const pr: Vcs.PrInfo = { + number: parsed.number, + url: parsed.url, + title: parsed.title, + state: stateMap[parsed.state] ?? "OPEN", + headRefName: parsed.headRefName, + baseRefName: parsed.baseRefName, + isDraft: parsed.isDraft ?? false, + mergeable: mergeableMap[parsed.mergeable] ?? "UNKNOWN", + reviewDecision: parsed.reviewDecision || null, + checksState, + checksUrl, + checksSummary, + } + + // Fetch unresolved comment count via lightweight GraphQL query + if (repo) { + pr.unresolvedCommentCount = await fetchUnresolvedCommentCount(repo.owner, repo.name, pr.number) + } + + return pr + } catch (e) { + log.warn("fetchForBranch failed", { error: e }) + return undefined + } + } + + function sanitizeOutput(output: string): string { + return output.replace(/(ghp_|github_pat_|gho_|ghu_|ghs_|ghr_)[a-zA-Z0-9_]+/g, "").slice(0, 500) + } + + async function ensureGithub() { + const info = await Vcs.info() + const github = info.github + if (!github?.available) { + throw new PrError({ code: "GH_NOT_INSTALLED", message: "GitHub CLI (gh) is not installed" }) + } + if (!github.authenticated) { + throw new PrError({ code: "GH_NOT_AUTHENTICATED", message: "Run `gh auth login` to authenticate" }) + } + return { info, github: github as Vcs.GithubCapability & { available: true; authenticated: true } } + } + + export async function get(): Promise { + const info = await Vcs.info() + return info.pr + } + + export async function draft(input: DraftInput): Promise { + await Vcs.refresh() + const info = await Vcs.info() + const branch = info.branch + if (!branch) { + throw new PrError({ code: "DRAFT_FAILED", message: "No current branch found" }) + } + + const base = input.base ?? info.defaultBranch ?? "main" + const cwd = Instance.worktree + + const [commits, diffStat, diffPatch] = await Promise.all([ + withTimeout( + $`git log --oneline ${base}..HEAD` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => ""), + 30_000, + ), + withTimeout( + $`git diff --stat ${base}...HEAD` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => ""), + 30_000, + ), + withTimeout( + $`git diff --minimal ${base}...HEAD` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => ""), + 30_000, + ), + ]) + + try { + const generated = await generateDraftObject({ + branch, + base, + commits: commits.trim().slice(0, 20_000), + diffStat: diffStat.trim().slice(0, 20_000), + diffPatch: diffPatch.trim().slice(0, 60_000), + }) + const title = cleanDraftTitle(generated.title, branch) + const body = cleanDraftBody(generated.body, title) + return { title, body } + } catch (e) { + log.error("pr draft failed", { error: e }) + throw new PrError({ code: "DRAFT_FAILED", message: "Failed to generate pull request draft" }) + } + } + + export async function create(input: CreateInput): Promise { + await Vcs.refresh() + const { info } = await ensureGithub() + const cwd = Instance.worktree + const branch = info.branch + + if (info.pr) { + return info.pr + } + + if (!branch) { + throw new PrError({ code: "CREATE_FAILED", message: "No current branch found" }) + } + + const remote = await Vcs.resolvePushRemote(branch) + if (!remote) { + throw new PrError({ code: "CREATE_FAILED", message: "No remote configured for current branch" }) + } + + const push = await $`git push -u ${remote} HEAD`.quiet().nothrow().cwd(cwd) + if (push.exitCode !== 0) { + const errorOutput = + push.stderr?.toString().trim() || push.stdout?.toString().trim() || "Failed to push branch automatically" + log.error("push failed", { output: errorOutput }) + throw new PrError({ code: "CREATE_FAILED", message: sanitizeOutput(errorOutput) }) + } + + if (input.base) { + const branches = info.branches ?? (await Vcs.fetchBranches()) + if (!branches.includes(input.base)) { + throw new PrError({ code: "CREATE_FAILED", message: `Base branch '${input.base}' does not exist` }) + } + } + + const args = ["gh", "pr", "create", "--title", input.title] + args.push("--body", input.body) + if (input.base) args.push("--base", input.base) + if (input.draft) args.push("--draft") + + const cmd = await withTimeout( + $`${args}` + .quiet() + .nothrow() + .cwd(cwd) + .catch((e: unknown) => ({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from(String(e)) })), + 60_000, + ) + + const result = cmd.stdout.toString() + const errorOut = cmd.stderr.toString() + + if (cmd.exitCode !== 0 || !result.trim()) { + log.error("pr create failed", { stdout: result, stderr: errorOut, exitCode: cmd.exitCode }) + throw new PrError({ code: "CREATE_FAILED", message: sanitizeOutput(errorOut.trim() || result.trim() || "Failed to create pull request") }) + } + + await Vcs.refresh() + const updated = await Vcs.info() + if (updated.pr) { + return updated.pr + } + + const prUrl = result.trim().split("\n").pop() ?? "" + const numberMatch = prUrl.match(/\/pull\/(\d+)/) + if (!numberMatch) { + throw new PrError({ code: "CREATE_FAILED", message: "Pull request was created but could not determine PR number from output" }) + } + return { + number: parseInt(numberMatch[1], 10), + url: prUrl, + title: input.title, + state: "OPEN", + headRefName: updated.branch || info.branch, + baseRefName: input.base ?? updated.defaultBranch ?? info.defaultBranch ?? "main", + isDraft: input.draft ?? false, + mergeable: "UNKNOWN", + reviewDecision: null, + checksState: null, + } + } + + export async function ready(_input: ReadyInput): Promise { + const { info } = await ensureGithub() + const cwd = Instance.worktree + + const currentPr = await get() + if (!currentPr) { + throw new PrError({ code: "NO_PR", message: "No pull request found for the current branch" }) + } + + if (!currentPr.isDraft) { + return currentPr + } + + const cmd = await withTimeout( + $`gh pr ready` + .quiet() + .nothrow() + .cwd(cwd) + .catch((e: unknown) => ({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from(String(e)) })), + 30_000, + ) + + const result = cmd.stdout.toString() + const errorOut = cmd.stderr.toString() + + if (cmd.exitCode !== 0) { + log.error("pr ready failed", { stdout: result, stderr: errorOut, exitCode: cmd.exitCode }) + throw new PrError({ code: "READY_FAILED", message: sanitizeOutput(errorOut.trim() || result.trim() || "Failed to mark PR as ready") }) + } + + await Vcs.refresh() + const updated = await Vcs.info() + return updated.pr ?? { ...currentPr, isDraft: false } + } + + export async function merge(input: MergeInput): Promise { + const { info } = await ensureGithub() + const cwd = Instance.worktree + + const currentPr = await get() + if (!currentPr) { + throw new PrError({ code: "NO_PR", message: "No pull request found for the current branch" }) + } + + if (currentPr.mergeable === "CONFLICTING") { + throw new PrError({ code: "MERGE_FAILED", message: "PR has merge conflicts that must be resolved first" }) + } + + const github = info.github + if (!github?.repo) { + throw new PrError({ code: "MERGE_FAILED", message: "Unable to determine repository information" }) + } + + const strategy = input.strategy ?? "squash" + const mergeMethod = strategy === "squash" ? "squash" : strategy === "merge" ? "merge" : "rebase" + + const args = [ + "gh", + "api", + `repos/${github.repo.owner}/${github.repo.name}/pulls/${currentPr.number}/merge`, + "-X", + "PUT", + "-f", + `merge_method=${mergeMethod}`, + ] + + const cmd = await withTimeout( + $`${args}` + .quiet() + .nothrow() + .cwd(cwd) + .catch((e: unknown) => ({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from(String(e)) })), + 60_000, + ) + + const responseText = cmd.stdout.toString().trim() + const errorOut = cmd.stderr.toString().trim() + + if (cmd.exitCode !== 0 || !responseText) { + let errorMessage = errorOut || responseText || "Failed to merge pull request" + try { + const errorJson = JSON.parse(errorOut || responseText) + if (errorJson.message) { + errorMessage = errorJson.message + } + } catch {} + log.error("pr merge failed", { stderr: errorOut, stdout: responseText, exitCode: cmd.exitCode }) + throw new PrError({ code: "MERGE_FAILED", message: sanitizeOutput(errorMessage) }) + } + + await Vcs.refresh() + const updated = await Vcs.info() + const result: Vcs.PrInfo = updated.pr ?? { ...currentPr, state: "MERGED" } + + if (input.deleteBranch === true) { + const branchToDelete = currentPr.headRefName + if (branchToDelete) { + try { + await deleteBranch({ branch: branchToDelete }) + } catch (e) { + log.warn("post-merge branch deletion failed", { branch: branchToDelete, error: e }) + result.branchDeleteFailed = true + } + } + } + + return result + } + + export async function deleteBranch(input: DeleteBranchInput): Promise { + await ensureGithub() + const cwd = Instance.worktree + + // Delete remote branch + const remote = await $`git push origin --delete ${input.branch}` + .quiet() + .nothrow() + .cwd(cwd) + .catch((e: unknown) => ({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from(String(e)) })) + + if (remote.exitCode !== 0) { + const errorOut = remote.stderr.toString().trim() + // Ignore "remote ref does not exist" — branch may already be deleted + if (!errorOut.includes("remote ref does not exist")) { + log.error("delete remote branch failed", { stderr: errorOut }) + throw new PrError({ code: "DELETE_BRANCH_FAILED", message: sanitizeOutput(errorOut || "Failed to delete remote branch") }) + } + } + + await Vcs.refresh() + } +} diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index dea25b91b43b..f29a18ddeb57 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -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", "PENDING"]).nullable().optional(), + checksUrl: z.string().optional(), + checksSummary: z + .object({ + total: z.number(), + passed: z.number(), + failed: z.number(), + pending: z.number(), + skipped: z.number(), + }) + .optional(), + unresolvedCommentCount: z.number().optional(), + branchDeleteFailed: z.boolean().optional(), + }) + .meta({ ref: "PrInfo" }) + export type PrInfo = z.infer + + export const GithubCapability = z + .object({ + available: z.boolean(), + authenticated: z.boolean(), + repo: z.object({ owner: z.string(), name: z.string() }).optional(), + host: z.string().optional(), + }) + .meta({ ref: "GithubCapability" }) + export type GithubCapability = z.infer export const Event = { BranchUpdated: BusEvent.define( @@ -19,93 +55,385 @@ export namespace Vcs { branch: z.string().optional(), }), ), + Updated: BusEvent.define( + "vcs.updated", + z.object({ + branch: z.string().optional(), + defaultBranch: z.string().nullable().optional(), + branches: z.array(z.string()).nullable().optional(), + dirty: z.number().nullable().optional(), + pr: PrInfo.nullable().optional(), + github: GithubCapability.nullable().optional(), + }), + ), } export const Info = z .object({ branch: z.string(), + defaultBranch: z.string().optional(), + branches: z.array(z.string()).optional(), + dirty: z.number().optional(), + pr: PrInfo.optional(), + github: GithubCapability.optional(), }) .meta({ ref: "VcsInfo", }) export type Info = z.infer - export interface Interface { - readonly init: () => Effect.Effect - readonly branch: () => Effect.Effect + async function currentBranch() { + const text = await gitText(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + if (!text || text === "HEAD") return + return text } - interface State { - current: string | undefined + function splitRemoteRef(ref: string) { + const i = ref.indexOf("/") + if (i <= 0) return undefined + return { + remote: ref.slice(0, i), + branch: ref.slice(i + 1), + } } - export class Service extends ServiceMap.Service()("@opencode/Vcs") {} + async function gitText(args: string[]) { + return $`${args}` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => x.trim()) + .catch(() => "") + } - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make( - Effect.fn("Vcs.state")((ctx) => - Effect.gen(function* () { - if (ctx.project.vcs !== "git") { - return { current: undefined } - } + export async function currentUpstream() { + const ref = await gitText(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) + if (!ref || ref.includes("fatal")) return + return splitRemoteRef(ref) + } - const getCurrentBranch = async () => { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: ctx.worktree, - }) - if (result.exitCode !== 0) return undefined - const text = result.text().trim() - return text || undefined - } + export async function resolvePushRemote(branch: string) { + const upstream = await currentUpstream() + if (upstream?.remote) return upstream.remote + + const branchRemote = await gitText(["git", "config", `branch.${branch}.pushRemote`]) + if (branchRemote) return branchRemote + + const remote = await gitText(["git", "config", `branch.${branch}.remote`]) + if (remote && remote !== ".") return remote + + const pushDefault = await gitText(["git", "config", "remote.pushDefault"]) + if (pushDefault) return pushDefault + + const remotes = await gitText(["git", "remote"]) + const list = remotes.split("\n").map((x) => x.trim()).filter(Boolean) + if (list.length === 1) return list[0] + if (list.includes("origin")) return "origin" + } + + export async function detectGithubCapability(): Promise { + const cwd = Instance.worktree + const authCmd = await withTimeout( + $`gh auth status` + .quiet() + .nothrow() + .cwd(cwd) + .catch((e) => ({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from(String(e)) })), + 30_000, + ) + const authText = (authCmd.stdout?.toString() ?? "") + (authCmd.stderr?.toString() ?? "") + const available = !authText.includes("not found") && !authText.includes("command not found") + if (!available) { + return { available: false, authenticated: false } + } + const authenticated = authText.includes("Logged in") || authCmd.exitCode === 0 + if (!authenticated) { + return { available: true, authenticated: false } + } + const repoCmd = await withTimeout( + $`gh repo view --json owner,name,url` + .quiet() + .nothrow() + .cwd(cwd) + .catch((e) => ({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from(String(e)) })), + 30_000, + ) + + let repo: { owner: string; name: string } | undefined + let host: string | undefined + if (repoCmd.exitCode !== 0) { + log.warn("gh repo view failed", { error: repoCmd.stderr.toString() }) + } else { + try { + const parsed = JSON.parse(repoCmd.stdout.toString()) + const ownerLogin = typeof parsed.owner === "string" ? parsed.owner : parsed.owner?.login + if (ownerLogin && parsed.name) { + repo = { owner: ownerLogin, name: parsed.name } + } + if (parsed.url) { + host = new URL(parsed.url).hostname + } + } catch (e) { + log.warn("gh repo view json parse failed", { error: String(e) }) + } + } + + return { available: true, authenticated: true, repo, host } + } + + export async function fetchDefaultBranch(): Promise { + const cwd = Instance.worktree + const result = await $`git rev-parse --abbrev-ref origin/HEAD` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => "") + const trimmed = result.trim() + if (trimmed && !trimmed.includes("fatal") && trimmed !== "origin/HEAD") { + return trimmed.replace("origin/", "") + } + const ghResult = await withTimeout( + $`gh repo view --json defaultBranchRef --jq .defaultBranchRef.name` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => ""), + 30_000, + ) + const ghTrimmed = ghResult.trim() + if (ghTrimmed && !ghTrimmed.includes("error")) { + return ghTrimmed + } + return undefined + } + + export async function fetchBranches(): Promise { + const cwd = Instance.worktree + const result = await $`git branch -r --format=${"%(refname:short)"}` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => "") + const branches = result + .split("\n") + .map((b) => b.trim()) + .filter((b) => b && !b.includes("->")) + .map((b) => splitRemoteRef(b)?.branch ?? b) + .filter((b, i, arr) => arr.indexOf(b) === i) + branches.sort((a, b) => a.localeCompare(b)) + return branches + } + + async function fetchDirtyCount(): Promise { + const cwd = Instance.worktree + const result = await $`git status --porcelain` + .quiet() + .nothrow() + .cwd(cwd) + .text() + .catch(() => "") + return result.split("\n").filter((l) => l.trim()).length + } + + async function fetchLocalInfo(): Promise> { + const branch = await currentBranch() + return { branch: branch ?? "", dirty: branch ? await fetchDirtyCount() : undefined } + } + + async function fetchRemoteInfo(_branch: string): Promise> { + const [github, defaultBranch, branches] = await Promise.all([ + detectGithubCapability(), + fetchDefaultBranch(), + fetchBranches(), + ]) + + let pr: PrInfo | undefined + if (github.authenticated) { + // Dynamic import to avoid circular dependency (pr.ts → vcs.ts) + const { PR } = await import("./pr") + pr = await PR.fetchForBranch(github.repo) + } + + return { + defaultBranch, + branches: branches.length > 0 ? branches : undefined, + pr, + github, + } + } + + async function fetchFullInfo(): Promise { + const local = await fetchLocalInfo() + if (!local.branch) return { branch: "" } + const remote = await fetchRemoteInfo(local.branch) + return { ...local, ...remote } + } + + export async function commit(message: string) { + const cwd = Instance.worktree + const add = await $`git add -A`.quiet().nothrow().cwd(cwd) + if (add.exitCode !== 0) { + throw new Error("git add failed: " + add.stderr.toString()) + } + const result = await $`git commit -m ${message}`.quiet().nothrow().cwd(cwd) + if (result.exitCode !== 0) { + throw new Error("git commit failed: " + result.stderr.toString()) + } + await refresh() + } + + export const POLL_INTERVAL_MS = 120_000 + export const POLL_INTERVAL_NO_PR_MULTIPLIER = 2 + export const LOCAL_DEBOUNCE_MS = 500 + export const REF_DEBOUNCE_MS = 2_000 + export const POLL_JITTER_MS = 10_000 + + function isGitRefChange(file: string): boolean { + return ( + file.endsWith("HEAD") || + file.includes(".git/refs/") || + file.endsWith("MERGE_HEAD") || + file.endsWith("COMMIT_EDITMSG") || + file.includes(".git/packed-refs") + ) + } - const value = { - current: yield* Effect.promise(() => getCurrentBranch()), + const state = Instance.state( + async () => { + if (Instance.project.vcs !== "git") { + return { info: async () => ({ branch: "" }), refresh: async () => {}, unsubscribe: undefined } + } + let current = await fetchFullInfo() + log.info("initialized", { branch: current.branch, pr: current.pr?.number }) + + let localDebounce: ReturnType | undefined + let refDebounce: ReturnType | undefined + let pollTimer: ReturnType | undefined + let hasActivePr = !!current.pr + let pollIntervalMs = hasActivePr ? POLL_INTERVAL_MS : POLL_INTERVAL_MS * POLL_INTERVAL_NO_PR_MULTIPLIER + + const restartPollTimer = () => { + if (pollTimer) clearTimeout(pollTimer) + + const scheduleNext = () => { + pollTimer = setTimeout( + async () => { + try { + await refreshFull() + } catch (e) { + log.error("poll refresh failed", { error: e }) + } + scheduleNext() + }, + pollIntervalMs + Math.random() * POLL_JITTER_MS, + ) + } + + scheduleNext() + } + + const publish = () => { + Bus.publish(Event.Updated, { + branch: current.branch, + defaultBranch: current.defaultBranch ?? null, + branches: current.branches ?? null, + dirty: current.dirty ?? null, + pr: current.pr ?? null, + github: current.github ?? null, + }) + } + + let refreshLock: Promise = Promise.resolve() + + const refreshLocal = async () => { + refreshLock = refreshLock.then(async () => { + const local = await fetchLocalInfo() + const branchChanged = local.branch !== current.branch + current = { ...current, ...local } + if (branchChanged) { + Bus.publish(Event.BranchUpdated, { branch: local.branch }) + // Branch changed — also refresh remote info + if (local.branch) { + const remote = await fetchRemoteInfo(local.branch) + current = { ...current, ...remote } } - log.info("initialized", { branch: value.current }) - - yield* Effect.acquireRelease( - Effect.sync(() => - Bus.subscribe( - FileWatcher.Event.Updated, - Instance.bind(async (evt) => { - if (!evt.properties.file.endsWith("HEAD")) return - const next = await getCurrentBranch() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - Bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ) - - return value - }), - ), - ) - - return Service.of({ - init: Effect.fn("Vcs.init")(function* () { - yield* InstanceState.get(state) - }), - branch: Effect.fn("Vcs.branch")(function* () { - return yield* InstanceState.use(state, (x) => x.current) - }), + } + publish() + }) + await refreshLock + } + + const refreshFull = async () => { + refreshLock = refreshLock.then(async () => { + const next = await fetchFullInfo() + const branchChanged = next.branch !== current.branch + const prChanged = next.pr?.number !== current.pr?.number + current = next + if (branchChanged) { + Bus.publish(Event.BranchUpdated, { branch: next.branch }) + } + + const hasPr = !!next.pr + if (hasPr !== hasActivePr || prChanged) { + hasActivePr = hasPr + pollIntervalMs = hasActivePr ? POLL_INTERVAL_MS : POLL_INTERVAL_MS * POLL_INTERVAL_NO_PR_MULTIPLIER + restartPollTimer() + } + + publish() + }) + await refreshLock + } + + const unsubscribeWatcher = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => { + const file = evt.properties.file + if (isGitRefChange(file)) { + // Git ref change (branch switch, commit, etc) — debounce a full refresh + if (refDebounce) clearTimeout(refDebounce) + refDebounce = setTimeout(refreshFull, REF_DEBOUNCE_MS) + return + } + // Regular file change — only refresh local (dirty count, branch) + if (localDebounce) clearTimeout(localDebounce) + localDebounce = setTimeout(refreshLocal, LOCAL_DEBOUNCE_MS) }) - }), + + restartPollTimer() + + return { + info: async () => current, + refresh: refreshFull, + unsubscribe: () => { + unsubscribeWatcher() + if (localDebounce) clearTimeout(localDebounce) + if (refDebounce) clearTimeout(refDebounce) + if (pollTimer) clearTimeout(pollTimer) + }, + } + }, + async (state) => { + state.unsubscribe?.() + }, ) - const runPromise = makeRunPromise(Service, layer) + export async function init() { + return state() + } + + export async function branch() { + return await state().then((s) => s.info().then((i) => i.branch)) + } - export function init() { - return runPromise((svc) => svc.init()) + export async function info(): Promise { + return await state().then((s) => s.info()) } - export function branch() { - return runPromise((svc) => svc.branch()) + export async function refresh() { + const s = await state() + await s.refresh() } } diff --git a/packages/opencode/src/server/routes/vcs.ts b/packages/opencode/src/server/routes/vcs.ts new file mode 100644 index 000000000000..d1aacf77eeee --- /dev/null +++ b/packages/opencode/src/server/routes/vcs.ts @@ -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({ + summary: "List remote branches", + description: "List all remote branches for the current repository.", + operationId: "vcs.branches", + responses: { + 200: { + description: "Branch list", + content: { + "application/json": { + schema: resolver(z.array(z.string())), + }, + }, + }, + }, + }), + async (c) => { + const branches = await Vcs.fetchBranches() + return c.json(branches) + }, + ) + .get( + "/pr", + describeRoute({ + summary: "Get current PR", + description: "Retrieve the pull request associated with the current branch, if any.", + operationId: "vcs.pr.get", + responses: { + 200: { + description: "PR info or null", + content: { + "application/json": { + schema: resolver(Vcs.PrInfo.nullable()), + }, + }, + }, + }, + }), + async (c) => { + const pr = await PR.get() + return c.json(pr ?? null) + }, + ) + .post( + "/pr", + describeRoute({ + summary: "Create PR", + description: "Create a pull request for the current branch. Idempotent — returns existing PR if one exists.", + operationId: "vcs.pr.create", + responses: { + 200: { + description: "Created or existing PR", + content: { + "application/json": { + schema: resolver(Vcs.PrInfo), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", PR.CreateInput), + async (c) => { + const input = c.req.valid("json") + const pr = await PR.create(input) + return c.json(pr) + }, + ) + .get( + "/pr/comments", + describeRoute({ + summary: "Get unresolved review comments", + description: + "Fetch unresolved review threads for the current PR, with a pre-formatted prompt block for the agent.", + operationId: "vcs.pr.comments", + responses: { + 200: { + description: "Unresolved review threads and prompt block", + content: { + "application/json": { + schema: resolver(PrComments.CommentsResponse), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const data = await PrComments.fetch() + return c.json(data) + }, + ) + .post( + "/pr/draft", + describeRoute({ + summary: "Generate PR draft", + description: "Generate an AI draft for the current branch's pull request title and body.", + operationId: "vcs.pr.draft", + responses: { + 200: { + description: "Draft PR content", + content: { + "application/json": { + schema: resolver(PR.DraftOutput), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", PR.DraftInput), + async (c) => { + const input = c.req.valid("json") + const draft = await PR.draft(input) + return c.json(draft) + }, + ) + .post( + "/pr/merge", + describeRoute({ + summary: "Merge PR", + description: "Merge the pull request for the current branch.", + operationId: "vcs.pr.merge", + responses: { + 200: { + description: "Merged PR", + content: { + "application/json": { + schema: resolver(Vcs.PrInfo), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", PR.MergeInput), + async (c) => { + const input = c.req.valid("json") + const pr = await PR.merge(input) + return c.json(pr) + }, + ) + .post( + "/pr/ready", + describeRoute({ + summary: "Mark PR as ready", + description: "Mark a draft pull request as ready for review.", + operationId: "vcs.pr.ready", + responses: { + 200: { + description: "PR marked as ready", + content: { + "application/json": { + schema: resolver(Vcs.PrInfo), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", PR.ReadyInput), + async (c) => { + const input = c.req.valid("json") + const pr = await PR.ready(input) + return c.json(pr) + }, + ) + .post( + "/pr/delete-branch", + describeRoute({ + summary: "Delete branch", + description: "Delete the remote branch after a PR has been merged.", + operationId: "vcs.pr.deleteBranch", + responses: { + 200: { + description: "Branch deleted", + content: { + "application/json": { + schema: resolver(z.object({ ok: z.boolean() })), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", PR.DeleteBranchInput), + async (c) => { + const input = c.req.valid("json") + await PR.deleteBranch(input) + return c.json({ ok: true }) + }, + ) + .post( + "/commit", + describeRoute({ + summary: "Commit all changes", + description: "Stage all changes and commit with the given message.", + operationId: "vcs.commit", + responses: { + 200: { + description: "Commit succeeded", + content: { + "application/json": { + schema: resolver(z.object({ ok: z.boolean() })), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", CommitInput), + async (c) => { + try { + const input = c.req.valid("json") + await Vcs.commit(input.message) + return c.json({ ok: true }) + } catch (e) { + if (e instanceof Error) { + return c.json({ code: "COMMIT_FAILED", message: e.message }, { status: 400 }) + } + throw e + } + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3cb..cef4b80136c9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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 : 400 else status = 500 return c.json(err.toObject(), { status }) } @@ -249,6 +251,7 @@ export namespace Server { .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/", EventRoutes()) + .route("/vcs", VcsRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes()) .post( @@ -312,30 +315,6 @@ export namespace Server { }) }, ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info), - }, - }, - }, - }, - }), - async (c) => { - const branch = await Vcs.branch() - return c.json({ - branch, - }) - }, - ) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 1bb8c1a69bf3..86c72ef7e14f 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -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> diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 11463b795023..65a3ad7bf806 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -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) => Promise, -) { - return withServices( - directory, - Layer.merge(FileWatcher.layer, Vcs.layer), - async (rt) => { - await rt.runPromise(FileWatcher.Service.use((s) => s.init())) - await rt.runPromise(Vcs.Service.use((s) => s.init())) - await Bun.sleep(500) - await body(rt) - }, - { provide: [watcherConfigLayer] }, - ) -} - -type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } - -/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ -function nextBranchUpdate(directory: string, timeout = 10_000) { - return new Promise((resolve, reject) => { - let settled = false - - const timer = setTimeout(() => { - if (settled) return - settled = true - GlobalBus.off("event", on) - reject(new Error("timed out waiting for BranchUpdated event")) - }, timeout) - - function on(evt: BranchEvent) { - if (evt.directory !== directory) return - if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return - if (settled) return - settled = true - clearTimeout(timer) - GlobalBus.off("event", on) - resolve(evt.payload.properties.branch) - } - - GlobalBus.on("event", on) - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describeVcs("Vcs", () => { - afterEach(async () => { - await Instance.disposeAll() - }) - - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) - - await withVcs(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch())) - expect(branch).toBeDefined() - expect(typeof branch).toBe("string") - }) - }) - - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() - - await withVcs(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch())) - expect(branch).toBeUndefined() - }) - }) +afterEach(async () => { + await Instance.disposeAll() +}) - test("publishes BranchUpdated when .git/HEAD changes", async () => { +describe("Vcs.info", () => { + test("returns no branch data when git is detached at HEAD", async () => { await using tmp = await tmpdir({ git: true }) - const branch = `test-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() + await $`git checkout --detach HEAD`.cwd(tmp.path).quiet() - await withVcs(tmp.path, async () => { - const pending = nextBranchUpdate(tmp.path) - - const head = path.join(tmp.path, ".git", "HEAD") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) - - const updated = await pending - expect(updated).toBe(branch) + const info = await Instance.provide({ + directory: tmp.path, + fn: async () => Vcs.info(), }) - }) - - test("branch() reflects the new branch after HEAD change", async () => { - await using tmp = await tmpdir({ git: true }) - const branch = `test-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() - - await withVcs(tmp.path, async (rt) => { - const pending = nextBranchUpdate(tmp.path) - const head = path.join(tmp.path, ".git", "HEAD") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) - - await pending - const current = await rt.runPromise(Vcs.Service.use((s) => s.branch())) - expect(current).toBe(branch) - }) + expect(info.branch).toBe("") + expect(info.defaultBranch).toBeUndefined() + expect(info.branches).toBeUndefined() + expect(info.github).toBeUndefined() + expect(info.pr).toBeUndefined() }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b6821322e2f3..6d734cabf961 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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 extends HeyApiClient { + /** + * Get current PR + * + * Retrieve the pull request associated with the current branch, if any. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/pr", + ...options, + ...params, + }) + } + + /** + * Create PR + * + * Create a pull request for the current branch. Idempotent — returns existing PR if one exists. + */ + public create( + parameters?: { + directory?: string + workspace?: string + prCreateInput?: PrCreateInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "prCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/pr", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Get unresolved review comments + * + * Fetch unresolved review threads for the current PR, with a pre-formatted prompt block for the agent. + */ + public comments( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/pr/comments", + ...options, + ...params, + }) + } + + /** + * Generate PR draft + * + * Generate an AI draft for the current branch's pull request title and body. + */ + public draft( + parameters?: { + directory?: string + workspace?: string + prDraftInput?: PrDraftInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "prDraftInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/pr/draft", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Merge PR + * + * Merge the pull request for the current branch. + */ + public merge( + parameters?: { + directory?: string + workspace?: string + prMergeInput?: PrMergeInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "prMergeInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/pr/merge", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Mark PR as ready + * + * Mark a draft pull request as ready for review. + */ + public ready( + parameters?: { + directory?: string + workspace?: string + prReadyInput?: PrReadyInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "prReadyInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/pr/ready", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Delete branch + * + * Delete the remote branch after a PR has been merged. + */ + public deleteBranch( + parameters?: { + directory?: string + workspace?: string + prDeleteBranchInput?: PrDeleteBranchInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "prDeleteBranchInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/pr/delete-branch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Vcs extends HeyApiClient { + /** + * Get VCS info + * + * Retrieve version control system (VCS) information for the current project, including branch, PR, and GitHub capability. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs", + ...options, + ...params, + }) + } + + /** + * List remote branches + * + * List all remote branches for the current repository. + */ + public branches( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/branches", + ...options, + ...params, + }) + } + + /** + * Commit all changes + * + * Stage all changes and commit with the given message. + */ + public commit( + parameters?: { + directory?: string + workspace?: string + vcsCommitInput?: VcsCommitInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "vcsCommitInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/commit", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _pr?: Pr + get pr(): Pr { + return (this._pr ??= new Pr({ client: this.client })) + } +} + export class Auth2 extends HeyApiClient { /** * Remove MCP OAuth @@ -3663,38 +4036,6 @@ export class Path extends HeyApiClient { } } -export class Vcs extends HeyApiClient { - /** - * Get VCS info - * - * Retrieve version control system (VCS) information for the current project, such as git branch. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs", - ...options, - ...params, - }) - } -} - export class Command extends HeyApiClient { /** * List commands @@ -3986,6 +4327,11 @@ export class OpencodeClient extends HeyApiClient { return (this._event ??= new Event({ client: this.client })) } + private _vcs?: Vcs + get vcs(): Vcs { + return (this._vcs ??= new Vcs({ client: this.client })) + } + private _mcp?: Mcp get mcp(): Mcp { return (this._mcp ??= new Mcp({ client: this.client })) @@ -4006,11 +4352,6 @@ export class OpencodeClient extends HeyApiClient { return (this._path ??= new Path({ client: this.client })) } - private _vcs?: Vcs - get vcs(): Vcs { - return (this._vcs ??= new Vcs({ client: this.client })) - } - private _command?: Command get command(): Command { return (this._command ??= new Command({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f7aab687e663..2ca7d359eb54 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 | null + dirty?: number | null + pr?: PrInfo | null + github?: GithubCapability | null + } +} + export type EventWorkspaceReady = { type: "workspace.ready" properties: { @@ -995,6 +1040,7 @@ export type Event = | EventSessionDiff | EventSessionError | EventVcsBranchUpdated + | EventVcsUpdated | EventWorkspaceReady | EventWorkspaceFailed | EventPtyCreated @@ -1851,6 +1897,72 @@ export type File = { status: "added" | "deleted" | "modified" } +export type VcsInfo = { + branch: string + defaultBranch?: string + branches?: Array + dirty?: number + pr?: PrInfo + github?: GithubCapability +} + +export type PrCreateInput = { + title: string + body: string + base?: string + draft?: boolean +} + +export type ReviewComment = { + id: number + author: string + authorIsBot: boolean + body: string + path: string + line: number | null + diffHunk?: string +} + +export type ReviewThread = { + id: string + isResolved: boolean + path: string + line: number | null + comments: Array +} + +export type PrCommentsResponse = { + threads: Array + promptBlock: string + unresolvedCount: number +} + +export type PrDraftOutput = { + title: string + body: string +} + +export type PrDraftInput = { + base?: string +} + +export type PrMergeInput = { + strategy?: "merge" | "squash" | "rebase" + deleteBranch?: boolean +} + +export type PrReadyInput = { + [key: string]: unknown +} + +export type PrDeleteBranchInput = { + branch: string +} + +export type VcsCommitInput = { + message: string +} + export type McpStatusConnected = { status: "connected" } @@ -1888,10 +2000,6 @@ export type Path = { directory: string } -export type VcsInfo = { - branch: string -} - export type Command = { name: string description?: string @@ -4248,6 +4356,279 @@ export type EventSubscribeResponses = { export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type VcsBranchesData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/branches" +} + +export type VcsBranchesResponses = { + /** + * Branch list + */ + 200: Array +} + +export type VcsBranchesResponse = VcsBranchesResponses[keyof VcsBranchesResponses] + +export type VcsPrGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr" +} + +export type VcsPrGetResponses = { + /** + * PR info or null + */ + 200: PrInfo | null +} + +export type VcsPrGetResponse = VcsPrGetResponses[keyof VcsPrGetResponses] + +export type VcsPrCreateData = { + body?: PrCreateInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr" +} + +export type VcsPrCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsPrCreateError = VcsPrCreateErrors[keyof VcsPrCreateErrors] + +export type VcsPrCreateResponses = { + /** + * Created or existing PR + */ + 200: PrInfo +} + +export type VcsPrCreateResponse = VcsPrCreateResponses[keyof VcsPrCreateResponses] + +export type VcsPrCommentsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr/comments" +} + +export type VcsPrCommentsErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type VcsPrCommentsError = VcsPrCommentsErrors[keyof VcsPrCommentsErrors] + +export type VcsPrCommentsResponses = { + /** + * Unresolved review threads and prompt block + */ + 200: PrCommentsResponse +} + +export type VcsPrCommentsResponse = VcsPrCommentsResponses[keyof VcsPrCommentsResponses] + +export type VcsPrDraftData = { + body?: PrDraftInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr/draft" +} + +export type VcsPrDraftErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsPrDraftError = VcsPrDraftErrors[keyof VcsPrDraftErrors] + +export type VcsPrDraftResponses = { + /** + * Draft PR content + */ + 200: PrDraftOutput +} + +export type VcsPrDraftResponse = VcsPrDraftResponses[keyof VcsPrDraftResponses] + +export type VcsPrMergeData = { + body?: PrMergeInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr/merge" +} + +export type VcsPrMergeErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type VcsPrMergeError = VcsPrMergeErrors[keyof VcsPrMergeErrors] + +export type VcsPrMergeResponses = { + /** + * Merged PR + */ + 200: PrInfo +} + +export type VcsPrMergeResponse = VcsPrMergeResponses[keyof VcsPrMergeResponses] + +export type VcsPrReadyData = { + body?: PrReadyInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr/ready" +} + +export type VcsPrReadyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type VcsPrReadyError = VcsPrReadyErrors[keyof VcsPrReadyErrors] + +export type VcsPrReadyResponses = { + /** + * PR marked as ready + */ + 200: PrInfo +} + +export type VcsPrReadyResponse = VcsPrReadyResponses[keyof VcsPrReadyResponses] + +export type VcsPrDeleteBranchData = { + body?: PrDeleteBranchInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/pr/delete-branch" +} + +export type VcsPrDeleteBranchErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type VcsPrDeleteBranchError = VcsPrDeleteBranchErrors[keyof VcsPrDeleteBranchErrors] + +export type VcsPrDeleteBranchResponses = { + /** + * Branch deleted + */ + 200: { + ok: boolean + } +} + +export type VcsPrDeleteBranchResponse = VcsPrDeleteBranchResponses[keyof VcsPrDeleteBranchResponses] + +export type VcsCommitData = { + body?: VcsCommitInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/commit" +} + +export type VcsCommitErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsCommitError = VcsCommitErrors[keyof VcsCommitErrors] + +export type VcsCommitResponses = { + /** + * Commit succeeded + */ + 200: { + ok: boolean + } +} + +export type VcsCommitResponse = VcsCommitResponses[keyof VcsCommitResponses] + export type McpStatusData = { body?: never path?: never @@ -4833,25 +5214,6 @@ export type PathGetResponses = { export type PathGetResponse = PathGetResponses[keyof PathGetResponses] -export type VcsGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} - -export type VcsGetResponses = { - /** - * VCS info - */ - 200: VcsInfo -} - -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] - export type CommandListData = { body?: never path?: never diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx index 7187e4ac300b..51323e775286 100644 --- a/packages/ui/src/components/checkbox.tsx +++ b/packages/ui/src/components/checkbox.tsx @@ -11,7 +11,7 @@ export interface CheckboxProps extends ParentProps + diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1e74763ae2d8..7588db1928da 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -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"] { diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index edc2eee9a400..01ce51f60ab6 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -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); diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index e2eaf107a675..f727dd9629cf 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -78,6 +78,8 @@ const icons = { "layout-bottom-full": ``, "dot-grid": ``, "circle-check": ``, + "circle-check-fill": ``, + "circle-fill": ``, copy: ``, check: ``, photo: ``, @@ -102,6 +104,7 @@ const icons = { link: ``, providers: ``, models: ``, + loader: ``, } export interface IconProps extends ComponentProps<"svg"> {