diff --git a/apps/desktop/electron/git-review-ops.ts b/apps/desktop/electron/git-review-ops.ts
index a44be7d3fe..393ede5f85 100644
--- a/apps/desktop/electron/git-review-ops.ts
+++ b/apps/desktop/electron/git-review-ops.ts
@@ -584,6 +584,111 @@ async function reviewShipInfo(repoPath, ghBin) {
}
}
+// GraphQL asks per branch, so the answer can't be crowded out the way a
+// `gh pr list` page can. Aliases let one request carry many branches; 50 keeps
+// the document well inside GitHub's node budget.
+const PR_QUERY_BRANCH_CHUNK = 50
+const PR_QUERY_BRANCH_CAP = 300
+
+const PR_NODE_FIELDS = 'number state isDraft isCrossRepository title url headRefName'
+
+function prQueryFor(owner, name, branches, numbers) {
+ const fields = [
+ ...branches.map(
+ (branch, i) =>
+ `b${i}: pullRequests(headRefName: ${JSON.stringify(branch)}, first: 5, ` +
+ `orderBy: {field: CREATED_AT, direction: DESC}) ` +
+ `{ nodes { ${PR_NODE_FIELDS} } }`
+ ),
+ // A PR recovered from a transcript is known by number, and asking for it
+ // directly also tells us its branch — so it lands in the same by-branch map
+ // as everything else.
+ ...numbers.map((number, i) => `n${i}: pullRequest(number: ${number}) { ${PR_NODE_FIELDS} }`)
+ ].join('\n')
+
+ return `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {\n${fields}\n} }`
+}
+
+const prPayload = pr => ({
+ branch: String(pr.headRefName),
+ draft: Boolean(pr.isDraft),
+ number: Number(pr.number) || 0,
+ state: String(pr.state || '').toLowerCase(),
+ title: String(pr.title || ''),
+ url: String(pr.url || '')
+})
+
+// The PR for each of the given branches, keyed by branch. Asks GitHub about the
+// branches we actually have sessions on rather than listing the repo's newest
+// PRs and hoping ours are in the page — on a busy repo they are not. One
+// GraphQL request per 50 branches; reads only.
+async function reviewPrList(repoPath, ghBin, branches, numbers) {
+ let cwd
+
+ try {
+ cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review PR list' })
+ } catch {
+ return { ghReady: false, prs: [] }
+ }
+
+ const wanted = [...new Set((branches || []).filter(Boolean).map(String))].slice(0, PR_QUERY_BRANCH_CAP)
+ const byNumber = [...new Set((numbers || []).map(Number).filter(Boolean))].slice(0, PR_QUERY_BRANCH_CAP)
+
+ if (wanted.length === 0 && byNumber.length === 0) {
+ return { ghReady: false, prs: [] }
+ }
+
+ const repo = await runGh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], cwd, ghBin)
+ const [owner, name] = repo.stdout.trim().split('/')
+
+ if (!repo.ok || !owner || !name) {
+ // gh missing, unauthenticated, or no GitHub remote — all "nothing to badge".
+ return { ghReady: false, prs: [] }
+ }
+
+ const prs = []
+ const chunks = []
+
+ for (let start = 0; start < wanted.length; start += PR_QUERY_BRANCH_CHUNK) {
+ chunks.push([wanted.slice(start, start + PR_QUERY_BRANCH_CHUNK), []])
+ }
+
+ for (let start = 0; start < byNumber.length; start += PR_QUERY_BRANCH_CHUNK) {
+ chunks.push([[], byNumber.slice(start, start + PR_QUERY_BRANCH_CHUNK)])
+ }
+
+ for (const [branchChunk, numberChunk] of chunks) {
+ const query = prQueryFor(owner, name, branchChunk, numberChunk)
+ const res = await runGh(['api', 'graphql', '-f', `query=${query}`], cwd, ghBin)
+
+ if (!res.ok) {
+ continue
+ }
+
+ try {
+ const repository = JSON.parse(res.stdout)?.data?.repository ?? {}
+
+ for (const key of Object.keys(repository)) {
+ // Asked for by number, so it's ours by construction — a fork PR can't
+ // be recovered from our own transcript. Asked for by branch, it has to
+ // prove it: fork PRs share our branch namespace, and a contributor's
+ // `main` is how a session on trunk ends up badged with a stranger's PR.
+ const pr = key.startsWith('n')
+ ? repository[key]
+ : (repository[key]?.nodes ?? []).find(node => node && !node.isCrossRepository)
+
+ if (pr?.headRefName) {
+ prs.push(prPayload(pr))
+ }
+ }
+ } catch {
+ // A malformed chunk drops its branches; the rest still resolve.
+ }
+ }
+
+ return { ghReady: true, prs }
+}
+
// Create a PR for the current branch (pushing first so gh has a remote ref),
// letting gh fill title/body from the commits. Returns the new PR url.
async function reviewCreatePr(repoPath, gitBin, ghBin) {
@@ -716,6 +821,7 @@ export {
reviewCreatePr,
reviewDiff,
reviewList,
+ reviewPrList,
reviewPush,
reviewRevert,
reviewRevParse,
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index 5c523f3c2a..77d93bea04 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -105,6 +105,7 @@ import {
reviewCreatePr,
reviewDiff,
reviewList,
+ reviewPrList,
reviewPush,
reviewRevert,
reviewRevParse,
@@ -11817,6 +11818,9 @@ ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
+ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) =>
+ reviewPrList(repoPath, resolveGhBinary(), branches, numbers)
+)
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts
index 0f18998117..d461aeaecc 100644
--- a/apps/desktop/electron/preload.ts
+++ b/apps/desktop/electron/preload.ts
@@ -229,6 +229,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
+ prList: (repoPath, branches, numbers) =>
+ ipcRenderer.invoke('hermes:git:review:prList', repoPath, branches, numbers),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},
diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
index 664196e336..0eeb831164 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { memo, useEffect } from 'react'
+import { PrTag } from '@/app/chat/pr-tag'
import { StatusRow } from '@/components/chat/status-row'
import {
type ActionItemSpec,
@@ -18,6 +19,7 @@ import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { openWorktreeDialog, registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
+import { $pullRequestsByBranch, branchPrKey, refreshPullRequests } from '@/store/pull-requests'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
@@ -77,6 +79,21 @@ export const CodingStatusRow = memo(function CodingStatusRow({
// only refreshed when the MAIN cwd probe happened to cover them).
useEffect(() => registerRepoStatusCwd(resolvedRepoPath), [resolvedRepoPath])
+ // The branch's PR, so the rail links to it instead of leaving you to go find
+ // it. One `gh` lookup for this one branch, TTL-cached in the store and shared
+ // with the sidebar's badges.
+ const prBranch = status?.detached ? null : status?.branch || null
+
+ useEffect(() => {
+ if (resolvedRepoPath && prBranch) {
+ void refreshPullRequests({ [resolvedRepoPath]: [prBranch] })
+ }
+ }, [resolvedRepoPath, prBranch])
+
+ const pr = useStore($pullRequestsByBranch)[
+ resolvedRepoPath && prBranch ? branchPrKey(resolvedRepoPath, prBranch) : ''
+ ]
+
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
@@ -213,6 +230,8 @@ export const CodingStatusRow = memo(function CodingStatusRow({
+ {pr && }
+
{/* Worktree path + copy — plain muted text, not a chip. Always in the
flex so hover doesn't reflow the row; opacity alone reveals the
pair. The path sizes to its content (the `flex-1` lives on the
diff --git a/apps/desktop/src/app/chat/pr-tag.tsx b/apps/desktop/src/app/chat/pr-tag.tsx
new file mode 100644
index 0000000000..bbd323304a
--- /dev/null
+++ b/apps/desktop/src/app/chat/pr-tag.tsx
@@ -0,0 +1,55 @@
+import { Codicon } from '@/components/ui/codicon'
+import { Tip } from '@/components/ui/tooltip'
+import type { HermesBranchPullRequest } from '@/global'
+import { cn } from '@/lib/utils'
+import { pullRequestBucket } from '@/store/pull-requests'
+
+// GitHub's own colour language, mapped onto our tokens: open is the "go" green,
+// merged the purple everyone reads as landed, draft and closed muted since
+// neither is waiting on you.
+const PR_STYLE: Record = {
+ closed: { className: 'text-(--ui-red)', icon: 'git-pull-request-closed' },
+ draft: { className: 'text-(--ui-text-quaternary)', icon: 'git-pull-request-draft' },
+ merged: { className: 'text-(--ui-purple)', icon: 'git-merge' },
+ open: { className: 'text-(--ui-green)', icon: 'git-pull-request' }
+}
+
+export function openPullRequest(pr: HermesBranchPullRequest): void {
+ if (pr.url) {
+ void window.hermesDesktop?.openExternal?.(pr.url)
+ }
+}
+
+/** The branch's PR as a row chip: state glyph plus number, tooltipped with the
+ * title, and a link to the PR on click. Identity like {@link ProfileTag} —
+ * never a status dot. */
+export function PrTag({ className, pr }: { className?: string; pr: HermesBranchPullRequest }) {
+ const style = PR_STYLE[pullRequestBucket(pr)] ?? PR_STYLE.open
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 6b0fc5dc05..3816afa694 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -260,6 +260,10 @@ declare global {
commitContext: (repoPath: string) => Promise<{ diff: string; recent: string }>
push: (repoPath: string) => Promise<{ ok: boolean }>
shipInfo: (repoPath: string) => Promise
+ // The PR on each of the given branches — plus any known only by
+ // number — for badging a list of sessions in one request instead of
+ // one `pr view` per checkout.
+ prList: (repoPath: string, branches: string[], numbers?: number[]) => Promise
createPr: (repoPath: string) => Promise<{ url: string }>
}
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
@@ -935,6 +939,23 @@ export interface HermesReviewPr {
number: number
}
+// One repo's PRs as reported by `gh pr list`, each tied to the branch it was
+// opened from — how a session row finds its own PR.
+export interface HermesBranchPullRequest {
+ branch: string
+ draft: boolean
+ number: number
+ /** `open` | `closed` | `merged`, lowercased from gh. */
+ state: string
+ title: string
+ url: string
+}
+
+export interface HermesRepoPullRequests {
+ ghReady: boolean
+ prs: HermesBranchPullRequest[]
+}
+
// gh availability/auth + the current branch's PR — drives the review pane's PR
// button (disabled when gh isn't ready, "Open PR" vs "Create PR" otherwise).
export interface HermesReviewShipInfo {
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 8cec4b6ada..83c5d06cfa 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -561,6 +561,23 @@ async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise; scanned: string[] }> {
+ return window.hermesDesktop.api<{
+ pull_requests: Record
+ scanned: string[]
+ }>({
+ path: '/api/profiles/sessions/pull-requests',
+ method: 'POST',
+ body: { ids }
+ })
+}
+
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise {
if (sidebarBatchEndpointMissing) {
return listSidebarSessionsLegacy(req)
diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts
index 9584bb278b..189046e0e7 100644
--- a/apps/desktop/src/lib/desktop-git.ts
+++ b/apps/desktop/src/lib/desktop-git.ts
@@ -2,6 +2,7 @@ import type {
HermesGitBaseBranch,
HermesGitBranch,
HermesGitWorktree,
+ HermesRepoPullRequests,
HermesRepoStatus,
HermesReviewList,
HermesReviewShipInfo
@@ -92,6 +93,9 @@ const remoteGit: GitBridge = {
shipInfo: repoPath => gitGet('review/ship-info', { path: repoPath }),
+ prList: (repoPath, branches, numbers) =>
+ gitPost('review/pr-list', { branches, numbers: numbers ?? [], path: repoPath }),
+
createPr: repoPath => gitPost('review/create-pr', { path: repoPath })
},
diff --git a/apps/desktop/src/store/pull-requests.ts b/apps/desktop/src/store/pull-requests.ts
new file mode 100644
index 0000000000..7ced84ee6c
--- /dev/null
+++ b/apps/desktop/src/store/pull-requests.ts
@@ -0,0 +1,193 @@
+import { atom } from 'nanostores'
+
+import type { HermesBranchPullRequest } from '@/global'
+import { scanSessionPullRequests, type SessionInfo } from '@/hermes'
+import { desktopGit } from '@/lib/desktop-git'
+import { Codecs, persistentAtom } from '@/lib/persisted'
+
+/** How a row's PR reads at a glance — and what the sidebar filters on. A
+ * session with no branch, no PR, or an unreachable `gh` is `none`. */
+export type PullRequestBucket = 'closed' | 'draft' | 'merged' | 'none' | 'open'
+
+// `gh pr list` is a network call per repo. The sidebar asks on mount, on
+// window focus, and whenever the set of repos on screen changes — this keeps
+// those from stacking into a burst of identical requests.
+const PR_STALE_MS = 60_000
+
+/** Every known PR keyed by `${repoRoot}\n${branch}` — the join a session row
+ * makes with its own `git_repo_root` + `git_branch`. */
+export const $pullRequestsByBranch = atom>({})
+
+/** Sessions whose PR isn't on the branch they recorded at start — the checkout
+ * moved mid-conversation, or the work went off to a worktree. Written when the
+ * desktop creates a PR and when one is recovered from a transcript. Holds the
+ * lookup key, not the PR, so state stays live through the same refresh as
+ * everything else. */
+export const $prBranchBySession = persistentAtom>(
+ 'hermes.desktop.prBranchBySession',
+ {},
+ Codecs.stringRecord
+)
+
+/** Sessions already scanned for a PR url. A transcript doesn't grow a new PR,
+ * so a miss is permanent and a hit is already in {@link $prBranchBySession} —
+ * either way the session is never scanned again. */
+const $prScannedSessions = persistentAtom('hermes.desktop.prScannedSessions', [], Codecs.stringArray)
+
+const fetchedAt = new Map()
+const inFlight = new Set()
+let scanUnavailable = false
+let scanInFlight = false
+
+// A session sitting on the trunk has no PR of its own, and asking GitHub about
+// "main" is how a stranger's fork branch — forks share our branch namespace —
+// ends up badged onto it. Never ask.
+const TRUNK_BRANCHES = new Set(['dev', 'develop', 'main', 'master', 'trunk'])
+
+export const branchPrKey = (repoRoot: string, branch: string): string => `${repoRoot}\n${branch}`
+/** A PR known only by number (recovered from a transcript), keyed so it can
+ * share the one map. GitHub answers by number just as happily as by branch. */
+export const numberPrKey = (repoRoot: string, number: number): string => `${repoRoot}\n#${number}`
+
+export function sessionPrKey(session: SessionInfo): null | string {
+ const stamped = $prBranchBySession.get()[session.id]
+
+ if (stamped) {
+ return stamped
+ }
+
+ const root = session.git_repo_root
+ const branch = session.git_branch
+
+ return root && branch && !TRUNK_BRANCHES.has(branch.toLowerCase()) ? branchPrKey(root, branch) : null
+}
+
+/** Bind a session to the branch it just opened a PR from. */
+export function stampSessionPrBranch(sessionId: string, repoRoot: string, branch: string): void {
+ if (!sessionId || !repoRoot || !branch) {
+ return
+ }
+
+ $prBranchBySession.set({ ...$prBranchBySession.get(), [sessionId]: branchPrKey(repoRoot, branch) })
+}
+
+/** Recover PRs the branch join can't see, from the sessions' own transcripts.
+ * A session that ran in the main checkout and worked in a worktree recorded
+ * `main` (or nothing) as its branch, but it ran `gh pr create` — whose output
+ * is a bare PR url, the one shape that's a claim rather than a mention. Scans
+ * each session at most once, ever. */
+export async function recoverSessionPullRequests(sessions: SessionInfo[]): Promise {
+ const scanned = new Set($prScannedSessions.get())
+ const roots = new Map()
+
+ for (const session of sessions) {
+ if (session.git_repo_root && !scanned.has(session.id) && !sessionPrKey(session)) {
+ roots.set(session.id, session.git_repo_root)
+ }
+ }
+
+ if (roots.size === 0 || scanUnavailable || scanInFlight) {
+ return
+ }
+
+ scanInFlight = true
+
+ try {
+ const { pull_requests: found, scanned: asked } = await scanSessionPullRequests([...roots.keys()])
+ const stamps = { ...$prBranchBySession.get() }
+
+ for (const [id, pr] of Object.entries(found)) {
+ const root = roots.get(id)
+
+ if (root) {
+ stamps[id] = numberPrKey(root, pr.number)
+ }
+ }
+
+ $prBranchBySession.set(stamps)
+ $prScannedSessions.set([...new Set([...scanned, ...asked])])
+ } catch {
+ // An older backend has no such route. Stop asking rather than retrying on
+ // every list refresh; the branch join still covers the common case.
+ scanUnavailable = true
+ } finally {
+ scanInFlight = false
+ }
+}
+
+export function pullRequestBucket(pr: HermesBranchPullRequest | undefined): PullRequestBucket {
+ if (!pr) {
+ return 'none'
+ }
+
+ if (pr.state === 'merged') {
+ return 'merged'
+ }
+
+ if (pr.state === 'closed') {
+ return 'closed'
+ }
+
+ return pr.draft ? 'draft' : 'open'
+}
+
+/** Pull PRs for the given lookups, grouped by the repo they live in. Each entry
+ * is a branch name, or `#` for a PR recovered from a transcript. Skips
+ * repos fetched recently or still in flight. Goes through the remote-aware git
+ * facade, so a desktop pointed at a remote gateway asks the BACKEND's `gh`
+ * about the backend's checkout. */
+export async function refreshPullRequests(lookupsByRepo: Record, force = false): Promise {
+ const review = desktopGit()?.review
+
+ if (!review?.prList) {
+ return
+ }
+
+ const now = Date.now()
+
+ const stale = Object.keys(lookupsByRepo).filter(
+ root => !inFlight.has(root) && (force || now - (fetchedAt.get(root) ?? 0) > PR_STALE_MS)
+ )
+
+ await Promise.all(
+ stale.map(async root => {
+ inFlight.add(root)
+
+ const lookups = lookupsByRepo[root]
+ const numbers = lookups.filter(l => l.startsWith('#')).map(l => Number(l.slice(1)))
+
+ try {
+ const { prs } = await review.prList(
+ root,
+ lookups.filter(l => !l.startsWith('#')),
+ numbers
+ )
+
+ fetchedAt.set(root, Date.now())
+
+ // Replace this repo's slice wholesale: a PR that closed since the last
+ // pull has to disappear, not linger as a stale merge of old and new.
+ const next = Object.fromEntries(
+ Object.entries($pullRequestsByBranch.get()).filter(([key]) => !key.startsWith(`${root}\n`))
+ )
+
+ for (const pr of prs) {
+ next[branchPrKey(root, pr.branch)] = pr
+
+ // The session that recovered it looks it up by number, and its branch
+ // may well be someone else's by now (or deleted).
+ if (numbers.includes(pr.number)) {
+ next[numberPrKey(root, pr.number)] = pr
+ }
+ }
+
+ $pullRequestsByBranch.set(next)
+ } catch {
+ // gh missing, unauthenticated, or off-repo — leave what we had.
+ fetchedAt.set(root, Date.now())
+ } finally {
+ inFlight.delete(root)
+ }
+ })
+ )
+}
diff --git a/apps/desktop/src/store/review.ts b/apps/desktop/src/store/review.ts
index c25c044b17..64ebd936c2 100644
--- a/apps/desktop/src/store/review.ts
+++ b/apps/desktop/src/store/review.ts
@@ -10,8 +10,9 @@ import { isExcludedPath } from '@/lib/excluded-paths'
import { requestOneShot } from '@/lib/oneshot'
import { Codecs, persistentAtom } from '@/lib/persisted'
-import { refreshRepoStatus } from './coding-status'
-import { $busy, $currentCwd } from './session'
+import { refreshRepoStatus, repoStatusForCwd } from './coding-status'
+import { stampSessionPrBranch } from './pull-requests'
+import { $busy, $currentCwd, $selectedStoredSessionId, $sessions } from './session'
import { $workspaceChangeTick } from './workspace-events'
// State for the review pane: the working-tree changed-file list, the selected
@@ -527,6 +528,17 @@ export async function createOrOpenPr(): Promise {
void window.hermesDesktop?.openExternal?.(url)
}
+ // The session recorded its branch when it started; the checkout may have
+ // moved since, so bind the conversation to the branch the PR actually came
+ // from — otherwise a session that began on trunk badges whatever else lives
+ // on trunk, or nothing.
+ const session = $sessions.get().find(s => s.id === $selectedStoredSessionId.get())
+ const branch = repoStatusForCwd(ctx.cwd).get()?.branch
+
+ if (session?.git_repo_root && branch) {
+ stampSessionPrBranch(session.id, session.git_repo_root, branch)
+ }
+
void refreshShipInfo()
})
}
diff --git a/hermes_cli/web_git.py b/hermes_cli/web_git.py
index 92389adda6..9b31d877c4 100644
--- a/hermes_cli/web_git.py
+++ b/hermes_cli/web_git.py
@@ -463,6 +463,96 @@ def review_ship_info(cwd: str) -> dict:
return {"ghReady": True, "pr": None}
+# GraphQL asks per branch, so the answer can't be crowded out the way a
+# `gh pr list` page can. Aliases let one request carry many branches; 50 keeps
+# the document well inside GitHub's node budget.
+_PR_QUERY_BRANCH_CHUNK = 50
+_PR_QUERY_BRANCH_CAP = 300
+
+
+_PR_NODE_FIELDS = "number state isDraft isCrossRepository title url headRefName"
+
+
+def _pr_query(owner: str, name: str, branches: list[str], numbers: list[int]) -> str:
+ fields = [
+ f"b{i}: pullRequests(headRefName: {json.dumps(branch)}, first: 5, "
+ f"orderBy: {{field: CREATED_AT, direction: DESC}}) "
+ f"{{ nodes {{ {_PR_NODE_FIELDS} }} }}"
+ for i, branch in enumerate(branches)
+ ]
+ # A PR recovered from a transcript is known by number, and asking for it
+ # directly also tells us its branch — so it lands in the same by-branch map
+ # as everything else.
+ fields += [f"n{i}: pullRequest(number: {n}) {{ {_PR_NODE_FIELDS} }}" for i, n in enumerate(numbers)]
+ return (
+ f"query {{ repository(owner: {json.dumps(owner)}, name: {json.dumps(name)}) {{\n"
+ + "\n".join(fields)
+ + "\n} }"
+ )
+
+
+def _pr_payload(pr: dict) -> dict:
+ return {
+ "branch": str(pr.get("headRefName")),
+ "draft": bool(pr.get("isDraft")),
+ "number": int(pr.get("number") or 0),
+ "state": str(pr.get("state") or "").lower(),
+ "title": str(pr.get("title") or ""),
+ "url": str(pr.get("url") or ""),
+ }
+
+
+def review_pr_list(cwd: str, branches: list[str], numbers: list[int] = None) -> dict:
+ """The PRs on the given branches (plus any asked for by number). Asks GitHub
+ about the branches we actually have sessions on rather than listing the
+ repo's newest PRs and hoping ours are in the page."""
+ if not _is_dir(cwd):
+ return {"ghReady": False, "prs": []}
+ wanted = list(dict.fromkeys(str(b) for b in (branches or []) if b))[:_PR_QUERY_BRANCH_CAP]
+ by_number = list(dict.fromkeys(int(n) for n in (numbers or []) if n))[:_PR_QUERY_BRANCH_CAP]
+ if not wanted and not by_number:
+ return {"ghReady": False, "prs": []}
+ repo_ok, repo_out = _gh(cwd, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
+ owner, _, name = repo_out.strip().partition("/")
+ if not repo_ok or not owner or not name:
+ # gh missing, unauthenticated, or no GitHub remote — all "nothing to badge".
+ return {"ghReady": False, "prs": []}
+
+ prs: list[dict] = []
+ chunks = [
+ (wanted[i : i + _PR_QUERY_BRANCH_CHUNK], [])
+ for i in range(0, len(wanted), _PR_QUERY_BRANCH_CHUNK)
+ ] + [
+ ([], by_number[i : i + _PR_QUERY_BRANCH_CHUNK])
+ for i in range(0, len(by_number), _PR_QUERY_BRANCH_CHUNK)
+ ]
+ for branch_chunk, number_chunk in chunks:
+ ok, out = _gh(cwd, ["api", "graphql", "-f", f"query={_pr_query(owner, name, branch_chunk, number_chunk)}"])
+ if not ok:
+ continue
+ try:
+ repository = (json.loads(out).get("data") or {}).get("repository") or {}
+ except json.JSONDecodeError:
+ continue # A malformed chunk drops its branches; the rest still resolve.
+ for key, field in repository.items():
+ if not field:
+ continue
+ if key.startswith("n"):
+ # Asked for by number, so it's ours by construction — a fork PR
+ # can't be recovered from our own transcript.
+ if field.get("headRefName"):
+ prs.append(_pr_payload(field))
+ continue
+ # Fork PRs share our branch namespace: a contributor's `main` is how
+ # a session sitting on trunk ends up badged with a stranger's closed
+ # PR. Only this repo's own branches describe our sessions.
+ nodes = field.get("nodes") or []
+ pr = next((n for n in nodes if n and not n.get("isCrossRepository")), None)
+ if pr and pr.get("headRefName"):
+ prs.append(_pr_payload(pr))
+ return {"ghReady": True, "prs": prs}
+
+
def review_create_pr(cwd: str) -> dict:
"""Create a PR for the current branch (push first), letting gh fill title/body."""
try:
diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py
index 03dd3a9736..3577ee312f 100644
--- a/hermes_cli/web_models.py
+++ b/hermes_cli/web_models.py
@@ -233,6 +233,18 @@ class GitFileBody(BaseModel):
file: Optional[str] = None
+class GitPrListBody(BaseModel):
+ path: str
+ branches: List[str] = []
+ # PRs a session recovered from its transcript, which we know by number
+ # rather than by the branch it came from.
+ numbers: List[int] = []
+
+
+class SessionPrScanBody(BaseModel):
+ ids: List[str] = []
+
+
class GitCommitBody(BaseModel):
path: str
message: str
diff --git a/hermes_cli/web_routers/git.py b/hermes_cli/web_routers/git.py
index 62384d13a4..f788e86fe2 100644
--- a/hermes_cli/web_routers/git.py
+++ b/hermes_cli/web_routers/git.py
@@ -16,6 +16,7 @@ from hermes_cli.web_models import (
GitPathBody,
GitFileBody,
GitCommitBody,
+ GitPrListBody,
GitWorktreeAddBody,
GitWorktreeRemoveBody,
GitBranchSwitchBody,
@@ -81,6 +82,11 @@ async def git_ship_info_route(path: str):
return await _git_op(_web_git.review_ship_info, _git_path(path))
+@router.post("/api/git/review/pr-list")
+async def git_pr_list_route(body: GitPrListBody):
+ return await _git_op(_web_git.review_pr_list, _git_path(body.path), body.branches, body.numbers)
+
+
@router.post("/api/git/review/stage")
async def git_stage_route(body: GitFileBody):
return await _git_op(_web_git.review_stage, _git_path(body.path), body.file)
diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py
index 075480f22e..31197c9720 100644
--- a/hermes_cli/web_routers/profiles.py
+++ b/hermes_cli/web_routers/profiles.py
@@ -13,7 +13,9 @@ late-binding seam in :mod:`hermes_cli.web_deps` so tests that
"""
import asyncio # noqa: F401 — used by handlers
+import json
import logging
+import re
import subprocess # noqa: F401
import sys # noqa: F401
import time # noqa: F401
@@ -33,6 +35,7 @@ from hermes_cli.web_models import (
ProfileDescriptionUpdate,
ProfileModelUpdate,
ProfileDescribeAuto,
+ SessionPrScanBody,
)
# Same logger the handlers used before extraction (identical logger object).
@@ -370,6 +373,83 @@ def get_profiles_sessions_sidebar(
}
+# `gh pr create` prints the PR url and nothing else, so a tool result whose
+# whole output IS a PR url means this session opened that PR. Anything looser —
+# a url inside prose, a `gh pr view` payload, an issue link — is a session
+# TALKING about a PR, which is not the same claim.
+_PR_URL_RE = re.compile(r"^https://github\.com/[\w.-]+/[\w.-]+/pull/(\d+)/?$")
+
+
+def _pr_url_from_tool_output(content: str) -> Optional[Tuple[int, str]]:
+ """The (number, url) a tool result announces, or None."""
+ try:
+ output = (json.loads(content) or {}).get("output")
+ except (json.JSONDecodeError, TypeError, AttributeError):
+ return None
+ if not isinstance(output, str):
+ return None
+ match = _PR_URL_RE.match(output.strip())
+ return (int(match.group(1)), match.group(0)) if match else None
+
+
+@sessions_router.post("/api/profiles/sessions/pull-requests")
+def post_profiles_sessions_pull_requests(body: SessionPrScanBody):
+ """The PR each of these sessions opened, recovered from its own transcript.
+
+ A session records the branch it started on, so the sidebar can join a row to
+ its PR — but a session that starts in the main checkout and does its work in
+ a worktree has no branch of its own, and its PR is invisible to that join.
+ The evidence is in the conversation: ``gh pr create`` ran, and its output is
+ a bare PR url. Scanning for exactly that shape recovers the link with no
+ inference (see ``_pr_url_from_tool_output``).
+
+ Read-only across every profile, and the caller is expected to ask once per
+ session and remember the answer — a session's transcript does not grow a
+ second PR.
+ """
+ from hermes_cli import profiles as profiles_mod
+
+ wanted = list(dict.fromkeys(s for s in (body.ids or []) if s))[:2000]
+ if not wanted:
+ return {"pull_requests": {}, "scanned": []}
+
+ try:
+ targets = [(info.name, info.path) for info in profiles_mod.list_profiles()]
+ except Exception:
+ _log.exception("POST /api/profiles/sessions/pull-requests: list_profiles failed")
+ targets = []
+ if not targets:
+ targets.append(("default", profiles_mod.get_profile_dir("default")))
+
+ found: Dict[str, Dict[str, Any]] = {}
+ for name, home in targets:
+ db_path = Path(home) / "state.db"
+ if not db_path.exists():
+ continue
+ try:
+ db = _open_session_db_at_path(db_path, read_only=True)
+ except Exception as exc:
+ _warn_profile_read_error(name, exc)
+ continue
+ try:
+ for pr in db.find_pr_url_messages(wanted):
+ parsed = _pr_url_from_tool_output(pr["content"])
+ if parsed:
+ number, url = parsed
+ # Ordered oldest-first, so a later `gh pr create` in the
+ # same conversation wins — a reopened/replacement PR is the
+ # one the session ended on.
+ found[pr["session_id"]] = {"number": number, "url": url}
+ except Exception as exc:
+ _warn_profile_read_error(name, exc)
+ finally:
+ db.close()
+
+ # Every id we looked at, so the caller can remember "asked, nothing there"
+ # and never scan this session again.
+ return {"pull_requests": found, "scanned": wanted}
+
+
@router.get("/api/profiles")
async def list_profiles_endpoint():
from hermes_cli import profiles as profiles_mod
diff --git a/hermes_state.py b/hermes_state.py
index c510ff41d1..c580be0dcc 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -7827,6 +7827,31 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
result.append(msg)
return result
+ def find_pr_url_messages(self, session_ids: List[str]) -> List[Dict[str, Any]]:
+ """Tool results in these sessions that mention a GitHub PR url.
+
+ A candidate scan, deliberately loose: it hands back every tool result
+ containing ``/pull/`` and leaves the caller to decide which ones make a
+ claim (see the desktop's PR recovery, which only accepts an output that
+ is a bare PR url — the signature of ``gh pr create``). Ordered
+ oldest-first per session so the caller can take the last match.
+ """
+ found: List[Dict[str, Any]] = []
+ ids = [s for s in session_ids if s]
+ for start in range(0, len(ids), 900): # SQLite's bound-variable ceiling.
+ chunk = ids[start : start + 900]
+ placeholders = ",".join("?" * len(chunk))
+ with self._read_ctx() as conn:
+ rows = conn.execute(
+ f"""SELECT session_id, content FROM messages
+ WHERE session_id IN ({placeholders})
+ AND role = 'tool' AND content LIKE '%/pull/%'
+ ORDER BY id ASC""",
+ chunk,
+ ).fetchall()
+ found.extend({"session_id": row[0], "content": row[1]} for row in rows)
+ return found
+
def get_messages_around(
self,
session_id: str,