feat(desktop): resolve a session's pull request

A session row can say whether its work is open, merged or closed, and link
to it. The join is the session's own repo + branch, asked of GitHub in one
batched GraphQL request per repo (branch aliases, not a `gh pr list` page
that a busy repo crowds ours out of), through the remote-aware git facade so
a desktop on a remote gateway asks the backend's `gh`.

Two ways a session's branch can't answer, both covered:

- It ran on trunk. Fork PRs share our branch namespace, so asking about
  `main` badges a stranger's PR onto it — trunk is never asked about, and
  cross-repository PRs are dropped server-side either way.
- It worked in a worktree, so the branch it recorded at start isn't where
  the PR came from. Creating a PR from the review pane binds the session to
  the branch it actually used, and for sessions that predate that, the PR is
  recovered from the transcript: `gh pr create` prints a bare PR url and
  nothing else, so a tool result whose whole output is one is a claim rather
  than a mention. Scanned read-only across profiles, once per session ever.
This commit is contained in:
Brooklyn Nicholson
2026-08-09 06:21:04 -05:00
parent c8318460e4
commit 21aaa8b4f8
15 changed files with 648 additions and 2 deletions
+106
View File
@@ -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,
+4
View File
@@ -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())
)
+2
View File
@@ -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)
}
},
@@ -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({
</span>
</button>
{pr && <PrTag pr={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
+55
View File
@@ -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<string, { className: string; icon: string }> = {
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 (
<Tip label={`#${pr.number} ${pr.title}`}>
<button
aria-label={`Open pull request #${pr.number}`}
// A flex box doesn't pass text-decoration down to its items, so the
// underline goes on the number itself rather than the chip.
className={cn(
'group/pr flex shrink-0 items-center gap-0.5 text-[0.625rem] leading-none tabular-nums',
style.className,
className
)}
onClick={event => {
// The row underneath opens the session on click and pins on
// shift-click; the chip is its own target and keeps the press.
event.preventDefault()
event.stopPropagation()
openPullRequest(pr)
}}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<Codicon name={style.icon} size="0.75rem" />
<span className="underline-offset-1 group-hover/pr:underline">{pr.number}</span>
</button>
</Tip>
)
}
+21
View File
@@ -260,6 +260,10 @@ declare global {
commitContext: (repoPath: string) => Promise<{ diff: string; recent: string }>
push: (repoPath: string) => Promise<{ ok: boolean }>
shipInfo: (repoPath: string) => Promise<HermesReviewShipInfo>
// 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<HermesRepoPullRequests>
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 {
+17
View File
@@ -561,6 +561,23 @@ async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<S
}
}
/** The PR each of these sessions opened, recovered from its own transcript —
* for sessions whose recorded branch can't answer (they started on trunk and
* did the work in a worktree). Also returns every id it looked at, so the
* caller can remember a miss and never ask again. */
export function scanSessionPullRequests(
ids: string[]
): Promise<{ pull_requests: Record<string, { number: number; url: string }>; scanned: string[] }> {
return window.hermesDesktop.api<{
pull_requests: Record<string, { number: number; url: string }>
scanned: string[]
}>({
path: '/api/profiles/sessions/pull-requests',
method: 'POST',
body: { ids }
})
}
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
if (sidebarBatchEndpointMissing) {
return listSidebarSessionsLegacy(req)
+4
View File
@@ -2,6 +2,7 @@ import type {
HermesGitBaseBranch,
HermesGitBranch,
HermesGitWorktree,
HermesRepoPullRequests,
HermesRepoStatus,
HermesReviewList,
HermesReviewShipInfo
@@ -92,6 +93,9 @@ const remoteGit: GitBridge = {
shipInfo: repoPath => gitGet<HermesReviewShipInfo>('review/ship-info', { path: repoPath }),
prList: (repoPath, branches, numbers) =>
gitPost<HermesRepoPullRequests>('review/pr-list', { branches, numbers: numbers ?? [], path: repoPath }),
createPr: repoPath => gitPost('review/create-pr', { path: repoPath })
},
+193
View File
@@ -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<Record<string, HermesBranchPullRequest>>({})
/** 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<Record<string, string>>(
'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<string[]>('hermes.desktop.prScannedSessions', [], Codecs.stringArray)
const fetchedAt = new Map<string, number>()
const inFlight = new Set<string>()
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<void> {
const scanned = new Set($prScannedSessions.get())
const roots = new Map<string, string>()
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 `#<number>` 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<string, string[]>, force = false): Promise<void> {
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)
}
})
)
}
+14 -2
View File
@@ -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> {
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()
})
}
+90
View File
@@ -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:
+12
View File
@@ -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
+6
View File
@@ -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)
+80
View File
@@ -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
+25
View File
@@ -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,