From 21aaa8b4f8d36ed8ea33b6df46622cc1596c748e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 9 Aug 2026 06:21:04 -0500 Subject: [PATCH] feat(desktop): resolve a session's pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/electron/git-review-ops.ts | 106 ++++++++++ apps/desktop/electron/main.ts | 4 + apps/desktop/electron/preload.ts | 2 + .../chat/composer/status-stack/coding-row.tsx | 19 ++ apps/desktop/src/app/chat/pr-tag.tsx | 55 +++++ apps/desktop/src/global.d.ts | 21 ++ apps/desktop/src/hermes.ts | 17 ++ apps/desktop/src/lib/desktop-git.ts | 4 + apps/desktop/src/store/pull-requests.ts | 193 ++++++++++++++++++ apps/desktop/src/store/review.ts | 16 +- hermes_cli/web_git.py | 90 ++++++++ hermes_cli/web_models.py | 12 ++ hermes_cli/web_routers/git.py | 6 + hermes_cli/web_routers/profiles.py | 80 ++++++++ hermes_state.py | 25 +++ 15 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/pr-tag.tsx create mode 100644 apps/desktop/src/store/pull-requests.ts 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,