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:
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+21
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user