feat: add GitHub PR integration via sidecar endpoints#5
Conversation
- Add /api/ext/pr/* endpoints to shared/extended-api.ts (info, create, commit, push, merge, ready, comments) — all backed by gh CLI, no upstream OpenCode changes needed - Add PrProvider context that polls pr/info and exposes branch + PR state with adaptive polling (2 min with PR, 5 min without) - Add PrButton component in session header showing Create PR or PR number pill with dropdown (merge, address comments, mark ready, open in GitHub) - Add PrCreateDialog with inline commit-before-push flow and dirty file guard - Add PrMergeDialog with strategy picker and ask-agent conflict fix - Add PrCommentsDialog showing unresolved review threads with agent prompt pre-fill - Wire onSendPrompt through SessionHeader into session input
There was a problem hiding this comment.
Pull request overview
Adds GitHub Pull Request management to the prefixable UI by introducing new sidecar /api/ext/pr/* endpoints (backed by gh CLI) and a frontend PR context + dialogs to create, merge, and address review comments from within a session.
Changes:
- Add PR-related extended API endpoints in
shared/extended-api.ts(info/create/commit/push/merge/ready/comments). - Introduce
PrProvidercontext to poll PR state and expose it to the UI. - Add session header PR pill + dialogs for PR creation, merge strategy, and unresolved review threads.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
shared/extended-api.ts |
Adds /api/ext/pr/* endpoints using git + gh CLI. |
app-prefixable/src/pages/session.tsx |
Wires SessionHeader to allow pre-filling the prompt input. |
app-prefixable/src/pages/directory-layout.tsx |
Wraps directory routes with PrProvider. |
app-prefixable/src/context/pr.tsx |
New polling context for PR/VCS state. |
app-prefixable/src/components/session-header.tsx |
Adds PR button to the header and new prop for sending prompts. |
app-prefixable/src/components/pr-button.tsx |
PR pill/dropdown entrypoint + dialog orchestration. |
app-prefixable/src/components/pr-create-dialog.tsx |
PR creation flow (optional commit, push, create). |
app-prefixable/src/components/pr-merge-dialog.tsx |
Merge strategy picker + conflict “ask agent” shortcut. |
app-prefixable/src/components/pr-comments-dialog.tsx |
Fetches unresolved review threads and builds an agent prompt. |
Comments suppressed due to low confidence (7)
app-prefixable/src/context/pr.tsx:67
- The helper is named
fetch_info, but the surrounding codebase uses camelCase for functions (e.g.connect,navigateToParent, etc.). Rename tofetchInfo(and update call sites) to match the established convention.
async function fetch_info() {
setLoading(true)
const dirParam = directory ? `?directory=${encodeURIComponent(directory)}` : ""
try {
const res = await fetch(prefix(`/api/ext/pr/info${dirParam}`))
if (res.ok) setInfo(await res.json() as VcsInfo)
} catch (e) {
console.error("[PR] fetch error:", e)
} finally {
setLoading(false)
}
scheduleNext()
}
app-prefixable/src/components/pr-create-dialog.tsx:102
- This copy/flow is misleading: if there are uncommitted changes and the user leaves the commit message blank, the UI still pushes and creates the PR, but
git pushwill not include uncommitted working tree changes—so the PR may be missing the intended changes. Also, the backend stages withgit add -u(tracked only), so untracked files won’t be included even with a commit message. Consider (a) requiring a commit message whendirty > 0, and/or (b) explicitly stating “tracked changes” and blocking PR creation when only untracked changes exist, or (c) switching the backend togit add -Aif including untracked is desired.
<p style={{ color: "var(--text-base)" }}>
You have {props.dirty} uncommitted file{props.dirty === 1 ? "" : "s"}.
Add a commit message to include them, or leave blank to push as-is.
</p>
app-prefixable/src/components/pr-button.tsx:97
- The “Create PR” button is shown whenever
info()is loaded andpris null. When the backend can’t determine a branch (non-git directory), it returnsbranch: nulland this UI will still offer PR creation (with an empty branch label), leading to confusing failures. Gate the create-PR UI oninfo()?.branchbeing non-null/defined (and optionally show a clearer “not a git repo” state).
<Show when={info() && !prData()}>
<button
onClick={() => setShowCreate(true)}
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-md transition-colors"
style={{ border: "1px solid var(--border-base)", color: "var(--text-base)" }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = "var(--surface-inset)")}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = "transparent")}
title="Create pull request"
>
<GitBranchPlus class="w-3.5 h-3.5" />
<span>Create PR</span>
</button>
app-prefixable/src/components/session-header.tsx:138
<header class="flex ... justify-between">now has both a dedicated PR-button container and a separate panel-toggles container as siblings. Withjustify-between, this will distribute space across 4 children and can push the PR button away from the toggle cluster (layout regression). Consider grouping the PR button and toggles into the same right-side flex container (or switch tojustify-end+ spacer) so they stay aligned together.
{/* Right side: PR button + panel toggles */}
<div class="flex items-center gap-2">
<PrButton onSendPrompt={props.onSendPrompt} />
</div>
<div class="flex items-center gap-1">
{/* MCP toggle */}
app-prefixable/src/components/pr-comments-dialog.tsx:46
- The request URL includes a
prquery parameter, but the backend/api/ext/pr/commentshandler ignores it and always usesgh pr viewin the given directory to determine the PR. Either remove the unusedprparameter from the client request, or update the backend to accept an explicit PR number to avoid confusion.
const dirParam = directory ? `&directory=${encodeURIComponent(directory)}` : ""
const res = await fetch(prefix(`/api/ext/pr/comments?pr=${props.pr.number}${dirParam}`))
if (!res.ok) throw new Error(await res.text())
app-prefixable/src/components/pr-merge-dialog.tsx:30
isApprovedis declared but never used, which will trigger the repo’s@typescript-eslint/no-unused-varswarning rule. Remove it or use it (e.g., to surface approval state in the dialog).
const hasConflicts = () => props.pr.mergeable === "CONFLICTING"
const isApproved = () => props.pr.reviewDecision === "APPROVED"
shared/extended-api.ts:376
- PR description says
POST /api/ext/pr/createwill "Push branch + create PR", but this handler only runsgh pr createand does not push. Either update the PR description/docs to match the implementation, or move the push logic into this endpoint to keep the contract accurate.
// POST /api/ext/pr/create
// Body: { directory, title, body?, base?, draft? }
if (path === "/api/ext/pr/create" && method === "POST") {
const body = await req.json() as Record<string, unknown>
const dir = (body.directory as string) || process.cwd()
const title = body.title as string | undefined
const prBody = body.body as string | undefined
const base = body.base as string | undefined
const draft = body.draft === true
if (!title || !title.trim()) {
return Response.json({ error: "title is required" }, { status: 400 })
}
if (base && !isValidBranch(base)) {
return Response.json({ error: "invalid base branch name" }, { status: 400 })
}
try {
const args = ["pr", "create", "--title", title.trim()]
if (prBody) { args.push("--body", prBody) }
else { args.push("--body", "") }
if (base) args.push("--base", base)
if (draft) args.push("--draft")
const out = await gh(args, dir)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // GET /api/ext/pr/info?directory=<path> | ||
| // Returns current branch + PR metadata (null when no PR exists) | ||
| if (path === "/api/ext/pr/info" && method === "GET") { | ||
| const dir = resolveDir(url) |
There was a problem hiding this comment.
The PR endpoints take directory from query/body and use it as a process cwd without applying the existing validatePath(..., getAllowedRoot()) checks used by other /api/ext/* endpoints. This allows clients to run git/gh in arbitrary server paths outside the configured workspace root. Please validate dir for all /api/ext/pr/* handlers (GET + POST) and return 403 when outside the allowed root.
| const dir = resolveDir(url) | |
| const dir = resolveDir(url) | |
| if (!validatePath(dir, getAllowedRoot())) { | |
| return Response.json({ error: "Forbidden" }, { status: 403 }) | |
| } |
shared/extended-api.ts
Outdated
| const branchResult = await $`git rev-parse --abbrev-ref HEAD`.cwd(dir).quiet().nothrow() | ||
| const branch = branchResult.stdout.toString().trim() | ||
| if (!branch || branchResult.exitCode !== 0) { | ||
| return Response.json({ branch: null, pr: null }) |
There was a problem hiding this comment.
When git rev-parse fails you return { branch: null, pr: null } but omit dirty. The frontend VcsInfo type expects dirty: number and uses it in the create PR dialog; returning an inconsistent shape can cause runtime undefined values. Return a consistent payload (e.g. { branch: null, dirty: 0, pr: null }).
| return Response.json({ branch: null, pr: null }) | |
| return Response.json({ branch: null, dirty: 0, pr: null }) |
shared/extended-api.ts
Outdated
| const args = ["pr", "merge", "--auto"] | ||
| if (!strategy || strategy === "merge") args.push("--merge") | ||
| else if (strategy === "squash") args.push("--squash") | ||
| else args.push("--rebase") | ||
| if (deleteBranch) args.push("--delete-branch") | ||
|
|
||
| await gh(args, dir) | ||
| return Response.json({ success: true }) |
There was a problem hiding this comment.
gh pr merge is invoked without --yes (or another non-interactive flag). gh pr merge commonly prompts for confirmation and can hang waiting for TTY input, which would stall this HTTP request. Add --yes (and keep specifying the merge method flags) to ensure the endpoint is always non-interactive.
- Rename fetch_info to fetchInfo (camelCase convention) - Require commit message when dirty files exist; clarify copy to say 'tracked files' and that untracked files won't be included - Gate 'Create PR' button on branch being non-null (non-git dirs) - Merge PR button into single right-side flex container in session header with a divider; fixes justify-between layout regression - Remove unused pr= query param from pr-comments-dialog fetch URL - Remove unused isApproved variable from pr-merge-dialog
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
app-prefixable/src/context/pr.tsx:66
fetch_infouses snake_case, which is inconsistent with the rest of the UI codebase’s camelCase helper naming (e.g.,handleSubmit,scheduleNext, etc.). Renaming tofetchInfo(and updating the timer callback) would keep naming consistent and easier to scan.
async function fetch_info() {
setLoading(true)
const dirParam = directory ? `?directory=${encodeURIComponent(directory)}` : ""
try {
const res = await fetch(prefix(`/api/ext/pr/info${dirParam}`))
if (res.ok) setInfo(await res.json() as VcsInfo)
} catch (e) {
console.error("[PR] fetch error:", e)
} finally {
setLoading(false)
}
scheduleNext()
app-prefixable/src/components/pr-create-dialog.tsx:102
- The copy here is misleading: leaving the commit message blank does not “push as-is” including uncommitted changes—
git pushwill only push committed work, so those dirty files will be excluded from the PR. Consider rewording to make it explicit that uncommitted changes won’t be included unless the user commits them (or change the flow to prevent PR creation while dirty unless a commit is made).
<p style={{ color: "var(--text-base)" }}>
You have {props.dirty} uncommitted file{props.dirty === 1 ? "" : "s"}.
Add a commit message to include them, or leave blank to push as-is.
</p>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async (open) => { | ||
| if (!open) return null | ||
| const dirParam = directory ? `directory=${encodeURIComponent(directory)}` : "" | ||
| const res = await fetch(prefix(`/api/ext/pr/comments${dirParam ? "?" + dirParam.slice(1) : ""}`)) |
There was a problem hiding this comment.
The query string construction is broken: dirParam is already directory=..., but the code does dirParam.slice(1), producing ?irectory=... (missing the leading d). This will call the endpoint without the intended directory parameter and likely fail/point at the wrong repo. Build the URL as /api/ext/pr/comments?${dirParam} (or set dirParam to include the leading ? and remove the slice).
| const res = await fetch(prefix(`/api/ext/pr/comments${dirParam ? "?" + dirParam.slice(1) : ""}`)) | |
| const res = await fetch(prefix(`/api/ext/pr/comments${dirParam ? `?${dirParam}` : ""}`)) |
| const dir = resolveDir(url) | ||
| if (!validatePath(dir, getAllowedRoot())) { | ||
| return Response.json({ error: "Forbidden" }, { status: 403 }) | ||
| } |
There was a problem hiding this comment.
validatePath returns a normalized absolute path, but this block only checks truthiness and then continues using the original dir value for .cwd(dir). If dir is relative, validation is effectively against allowedRoot/dir, while the git/gh commands run relative to process.cwd(), which can escape the allowed root. Capture the return value (e.g., const validatedDir = validatePath(...)) and use that for all subsequent .cwd(...) calls (same pattern appears in the other PR endpoints below).
| const body = await req.json() as Record<string, unknown> | ||
| const dir = (body.directory as string) || process.cwd() | ||
| if (!validatePath(dir, getAllowedRoot())) { | ||
| return Response.json({ error: "Forbidden" }, { status: 403 }) | ||
| } |
There was a problem hiding this comment.
Same issue as in pr/info: the code validates dir with validatePath(...) but then continues using the un-normalized dir value (and never uses the validated return value). Please store the validated path and use it for .cwd(...) / filesystem operations in this endpoint and the other POST PR endpoints (/commit, /push, /merge, /ready).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
shared/extended-api.ts:522
- Repo extraction for GraphQL is hard-coded to
github.com(/github\.com\/.../). This will fail for GitHub Enterprise URLs returned bygh(different hostnames), causing/pr/commentsto error even thoughghis otherwise configured correctly. Consider parsingprData.urlwithnew URL()and extracting owner/repo from the pathname, independent of hostname.
// Extract owner/repo from the PR URL
// e.g. https://github.com/owner/repo/pull/123
const match = prData.url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\//)
if (!match) {
return Response.json({ error: "could not parse repo from PR URL" }, { status: 500 })
}
const owner = match[1]
const repo = match[2]
app-prefixable/src/components/pr-create-dialog.tsx:102
- The copy says “leave blank to push as-is”, but pushing won’t include any uncommitted changes—those changes will simply be left out of the PR unless committed. Consider rewording to clarify that leaving it blank will exclude uncommitted changes, or block PR creation until the working tree is clean / changes are committed.
<p style={{ color: "var(--text-base)" }}>
You have {props.dirty} uncommitted file{props.dirty === 1 ? "" : "s"}.
Add a commit message to include them, or leave blank to push as-is.
</p>
shared/extended-api.ts:358
- PR description lists
POST /api/ext/pr/createas “Push branch + create PR”, but this endpoint only runsgh pr createand the push is handled separately by/api/ext/pr/push(and by the frontend flow). Consider updating the PR description/docs to reflect the actual behavior, or have this endpoint ensure the branch is published before creating the PR so the API matches its documented contract.
// POST /api/ext/pr/create
// Body: { directory, title, body?, base?, draft? }
if (path === "/api/ext/pr/create" && method === "POST") {
shared/extended-api.ts:347
- In
pr/info, thegh pr viewerror is always swallowed and treated as “no PR”. This will mask real failures (e.g.ghnot installed, not authenticated, network errors) and make the UI incorrectly show “Create PR”. Consider only suppressing the specific “no pull requests found” case (e.g. inspect stderr/exit code) and returning a 5xx with a clear error for other failures.
} catch {
// No PR for this branch — not an error
}
shared/extended-api.ts:335
dirtyis computed fromgit status --porcelain, which includes untracked files, but/api/ext/pr/commitstages only tracked changes (git add -u). This mismatch can lead to “dirty > 0” being shown when the backend commit endpoint can’t actually include the changes. Consider either excluding untracked files in the dirty count (e.g.--untracked-files=no) or updating the commit endpoint / UI copy to match the behavior.
// Check for uncommitted changes (dirty count)
const statusResult = await $`git status --porcelain`.cwd(validatedDir).quiet().nothrow()
const dirty = statusResult.stdout.toString().trim().split("\n").filter(Boolean).length
shared/extended-api.ts:442
git rev-parse --abbrev-ref HEADreturnsHEADwhen in a detached HEAD state. In that case this endpoint will attemptgit push -u origin HEAD, which is likely unintended and can fail in confusing ways. Consider treatingbranch === "HEAD"as an error (400) and returning a message indicating a branch must be checked out.
const branchResult = await $`git rev-parse --abbrev-ref HEAD`.cwd(validatedDir).quiet().nothrow()
const branch = branchResult.stdout.toString().trim()
if (!branch || branchResult.exitCode !== 0) {
return Response.json({ error: "could not determine current branch" }, { status: 400 })
}
await $`git push -u origin ${branch}`.cwd(validatedDir).quiet()
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Implements GitHub PR management directly in the UI, without requiring any changes to the upstream OpenCode server. Inspired by upstream PR anomalyco/opencode#15785 which is unlikely to merge soon — we implement the same feature as a sidecar layer.
How it works
All GitHub operations are handled by our existing
shared/extended-api.tssidecar (already used bydev.tsandserve-ui.ts), which shells out to theghCLI. The frontend polls/api/ext/pr/infofor state and renders the appropriate UI.New backend endpoints (
shared/extended-api.ts)GET /api/ext/pr/infogh pr viewPOST /api/ext/pr/createPOST /api/ext/pr/commitgit add -u)POST /api/ext/pr/pushPOST /api/ext/pr/mergePOST /api/ext/pr/readyGET /api/ext/pr/commentsNew frontend files
context/pr.tsx—PrProviderpollspr/info, adaptive polling (2 min with PR, 5 min without)components/pr-button.tsx— PR pill in session header; "Create PR" when no PR exists, otherwise shows PR number with dropdowncomponents/pr-create-dialog.tsx— Title, body, draft toggle; handles dirty files with inline commit before pushcomponents/pr-merge-dialog.tsx— Strategy picker, delete-branch toggle, conflict warning with "ask agent to fix" shortcutcomponents/pr-comments-dialog.tsx— Unresolved review thread selector; pre-fills agent prompt to address selected commentsUX flow
Requirements
ghCLI must be installed and authenticated in the server environment (standard in Kubeflow Notebooks)