Skip to content

feat: add GitHub PR integration via sidecar endpoints#5

Merged
geier merged 5 commits intomainfrom
feat/github-pr-integration
Mar 4, 2026
Merged

feat: add GitHub PR integration via sidecar endpoints#5
geier merged 5 commits intomainfrom
feat/github-pr-integration

Conversation

@geier
Copy link
Copy Markdown
Contributor

@geier geier commented Mar 4, 2026

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.ts sidecar (already used by dev.ts and serve-ui.ts), which shells out to the gh CLI. The frontend polls /api/ext/pr/info for state and renders the appropriate UI.

New backend endpoints (shared/extended-api.ts)

Endpoint Description
GET /api/ext/pr/info Branch name, dirty count, PR metadata via gh pr view
POST /api/ext/pr/create Push branch + create PR
POST /api/ext/pr/commit Stage tracked files and commit (git add -u)
POST /api/ext/pr/push Push current branch to remote
POST /api/ext/pr/merge Merge with squash/merge/rebase strategy
POST /api/ext/pr/ready Mark draft PR as ready
GET /api/ext/pr/comments Unresolved review threads via GitHub GraphQL

New frontend files

  • context/pr.tsxPrProvider polls pr/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 dropdown
  • components/pr-create-dialog.tsx — Title, body, draft toggle; handles dirty files with inline commit before push
  • components/pr-merge-dialog.tsx — Strategy picker, delete-branch toggle, conflict warning with "ask agent to fix" shortcut
  • components/pr-comments-dialog.tsx — Unresolved review thread selector; pre-fills agent prompt to address selected comments

UX flow

  • No PR: "Create PR" button appears in session header
  • Open PR: PR number pill with dropdown → merge, address comments, mark ready, open in GitHub
  • Unresolved comments: blue dot badge on PR pill; "Address comments" in dropdown pre-fills the chat input with fix instructions
  • Merge conflicts: merge dialog shows warning + "Ask agent to fix conflicts" which pre-fills the chat input
  • Merged/Closed: read-only status pill

Requirements

  • gh CLI must be installed and authenticated in the server environment (standard in Kubeflow Notebooks)

- 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PrProvider context 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 to fetchInfo (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 push will not include uncommitted working tree changes—so the PR may be missing the intended changes. Also, the backend stages with git add -u (tracked only), so untracked files won’t be included even with a commit message. Consider (a) requiring a commit message when dirty > 0, and/or (b) explicitly stating “tracked changes” and blocking PR creation when only untracked changes exist, or (c) switching the backend to git add -A if 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 and pr is null. When the backend can’t determine a branch (non-git directory), it returns branch: null and this UI will still offer PR creation (with an empty branch label), leading to confusing failures. Gate the create-PR UI on info()?.branch being 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. With justify-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 to justify-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 pr query parameter, but the backend /api/ext/pr/comments handler ignores it and always uses gh pr view in the given directory to determine the PR. Either remove the unused pr parameter 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

  • isApproved is declared but never used, which will trigger the repo’s @typescript-eslint/no-unused-vars warning 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/create will "Push branch + create PR", but this handler only runs gh pr create and 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)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const dir = resolveDir(url)
const dir = resolveDir(url)
if (!validatePath(dir, getAllowedRoot())) {
return Response.json({ error: "Forbidden" }, { status: 403 })
}

Copilot uses AI. Check for mistakes.
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 })
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }).

Suggested change
return Response.json({ branch: null, pr: null })
return Response.json({ branch: null, dirty: 0, pr: null })

Copilot uses AI. Check for mistakes.
Comment on lines +448 to +455
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 })
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
geier added 2 commits March 4, 2026 10:53
- 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_info uses snake_case, which is inconsistent with the rest of the UI codebase’s camelCase helper naming (e.g., handleSubmit, scheduleNext, etc.). Renaming to fetchInfo (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 push will 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) : ""}`))
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
const res = await fetch(prefix(`/api/ext/pr/comments${dirParam ? "?" + dirParam.slice(1) : ""}`))
const res = await fetch(prefix(`/api/ext/pr/comments${dirParam ? `?${dirParam}` : ""}`))

Copilot uses AI. Check for mistakes.
Comment on lines +320 to +323
const dir = resolveDir(url)
if (!validatePath(dir, getAllowedRoot())) {
return Response.json({ error: "Forbidden" }, { status: 403 })
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +362
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 })
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by gh (different hostnames), causing /pr/comments to error even though gh is otherwise configured correctly. Consider parsing prData.url with new 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/create as “Push branch + create PR”, but this endpoint only runs gh pr create and 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, the gh pr view error is always swallowed and treated as “no PR”. This will mask real failures (e.g. gh not 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

  • dirty is computed from git status --porcelain, which includes untracked files, but /api/ext/pr/commit stages 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 HEAD returns HEAD when in a detached HEAD state. In that case this endpoint will attempt git push -u origin HEAD, which is likely unintended and can fail in confusing ways. Consider treating branch === "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.

@geier geier merged commit 9890a2b into main Mar 4, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants