diff --git a/docs/design/worktree-manager.md b/docs/design/worktree-manager.md index adc102e7..ce1312b1 100644 --- a/docs/design/worktree-manager.md +++ b/docs/design/worktree-manager.md @@ -169,7 +169,12 @@ Intents: `ResourceResolver` proves a resource for that path. The core ships no resolver. - `DiffIntent {repository, worktreePath, ref, head}` for a future #332 caller. -- Related PR is always `unavailable: No pull request evidence yet` (#338). +- Related PR (`p`, `openPullRequest`): available only with evidence from the host's cached `/github` listing + (`ControllerOptions.pullRequests`, never fetched by the manager). `relatedPullRequests` counts a PR only when its + head branch, in the PR's own repository, is exactly the worktree's branch: fork PRs with the same branch name, + PRs whose head repository GitHub did not report, detached worktrees and partial names are not evidence. Whether + the worktree's HEAD is the PR's head commit is reported separately (`atHead`). Without a listing the action says + `No pull request evidence yet`. Rendering: selection is a marker (`›`/`>`) plus weight, never color alone; `level: 'none'` emits no escape bytes; Safe glyphs are pure ASCII (and use @@ -212,6 +217,6 @@ terminals window whole entries around the selection with an `a-b/N` indicator. - NMSh session focus, tmux focus, and opening a new session for a worktree. - #331 sidebar quick navigation (can reuse `discoverWorktrees` and the model). - #332 diff panel (consumes `DiffIntent`). -- #338 related PR evidence. +- Passing the `/github` listing to `/worktrees` as `pullRequests` and opening the PR in `/github` on `openPullRequest` (TerminalApp). - #329 managed-target focus context. - Physical terminal QA. diff --git a/src/githubWorkspace/fixtures.ts b/src/githubWorkspace/fixtures.ts index 8523cf9e..6d508ad7 100644 --- a/src/githubWorkspace/fixtures.ts +++ b/src/githubWorkspace/fixtures.ts @@ -57,6 +57,7 @@ for (let i = 0; i < 20; i++) ISSUES.push({number: 100 - i, title: `Backlog item function rawPr(pr: RawPr): Record { return {__typename: 'PullRequest', number: pr.number, title: pr.title, url: `https://github.com/${OWNER}/${REPO}/pull/${pr.number}`, state: pr.state, isDraft: pr.isDraft ?? false, updatedAt: hoursAgo(pr.updated), headRefName: pr.head, baseRefName: pr.base ?? 'master', + headRepository: {nameWithOwner: pr.number === 315 ? 'contributor/notMyShell' : `${OWNER}/${REPO}`}, additions: pr.additions, deletions: pr.deletions, changedFiles: pr.changedFiles, reviewDecision: pr.review, mergeable: pr.mergeable, author: pr.author === null ? null : {login: pr.author ?? OWNER}, repository, commits: pr.rollup === undefined ? {nodes: []} : {nodes: [{commit: {statusCheckRollup: pr.rollup === null ? null : {state: pr.rollup}}}]}}; diff --git a/src/githubWorkspace/model.ts b/src/githubWorkspace/model.ts index 025f23a3..6a2efb84 100644 --- a/src/githubWorkspace/model.ts +++ b/src/githubWorkspace/model.ts @@ -74,6 +74,10 @@ export interface PrSummary { state: PrState; draft: boolean; headRef?: string; + /** `owner/name` of the repository holding the head branch; differs from the PR's repository for a fork. */ + headRepository?: string; + /** The head commit GitHub reports (40 hex), when reported. */ + headSha?: string; baseRef?: string; updatedAt?: string; reviewDecision: ReviewDecision; diff --git a/src/githubWorkspace/queries.ts b/src/githubWorkspace/queries.ts index a787e209..977a90d6 100644 --- a/src/githubWorkspace/queries.ts +++ b/src/githubWorkspace/queries.ts @@ -13,7 +13,8 @@ export const SEARCH_QUERY = `query($q: String!, $first: Int!, $after: String) { nodes { __typename ... on PullRequest { - number title url state isDraft updatedAt headRefName baseRefName + number title url state isDraft updatedAt headRefName baseRefName headRefOid + headRepository { nameWithOwner } additions deletions changedFiles reviewDecision mergeable author { login } repository { name owner { login } } diff --git a/src/githubWorkspace/source.ts b/src/githubWorkspace/source.ts index 9a6488f9..484c3fd9 100644 --- a/src/githubWorkspace/source.ts +++ b/src/githubWorkspace/source.ts @@ -84,6 +84,8 @@ function mapPrSummary(node: Json): PrSummary | undefined { state: mapPrState(node.state), draft: node.isDraft === true, headRef: optionalLine(node.headRefName, LIMITS.nameLength * 2), + headRepository: optionalLine(obj(node.headRepository).nameWithOwner, 200), + headSha: /^[0-9a-f]{40}$/u.test(String(node.headRefOid)) ? String(node.headRefOid) : undefined, baseRef: optionalLine(node.baseRefName, LIMITS.nameLength * 2), updatedAt: str(node.updatedAt), reviewDecision: 'reviewDecision' in node ? mapReviewDecision(node.reviewDecision) : 'unknown', diff --git a/src/worktrees/controller.ts b/src/worktrees/controller.ts index 6d02e046..51fded2e 100644 --- a/src/worktrees/controller.ts +++ b/src/worktrees/controller.ts @@ -1,3 +1,5 @@ +import type {PrSummary} from '../githubWorkspace/model.js'; +import {relatedPullRequests, type RelatedPr} from './relatedPr.js'; import type {Key} from '../terminal/keys.js'; import type {GitRunner} from './git.js'; import {discoverWorktrees, type DiscoveryOptions} from './discovery.js'; @@ -37,7 +39,9 @@ export type KeyOutcome = | {readonly kind: 'requestNewWorktree'} | {readonly kind: 'removed'; readonly path: string} | {readonly kind: 'created'; readonly path: string} - | {readonly kind: 'back'}; + | {readonly kind: 'back'} + /** Open the related pull request in /github (the host decides how). */ + | {readonly kind: 'openPullRequest'; readonly pr: PrSummary}; export type Review = | {readonly kind: 'remove'; readonly plan: RemovalPlan} @@ -63,6 +67,11 @@ export interface ControllerOptions { readonly currentPath?: () => string | undefined; readonly resources?: ResourceResolver; readonly discovery?: DiscoveryOptions; + /** + * Pull requests the host already holds (the /github service's cached listing; never fetched here), for related-PR + * evidence. Without it the related-PR action stays unavailable. + */ + readonly pullRequests?: () => {readonly repository: string; readonly prs: readonly PrSummary[]} | undefined; } export function searchText(worktree: WorktreeRecord): string { @@ -131,6 +140,12 @@ export class WorktreeManagerController { return {kind: 'diff', repository, worktreePath: worktree.path, ref: worktree.ref, head: worktree.head}; } + /** Related pull requests for the selected worktree, from the host's cached listing (see relatedPullRequests). */ + relatedPrs(worktree = this.selected()): RelatedPr[] { + const listing = this.options.pullRequests?.(); + return worktree && listing ? relatedPullRequests(worktree, listing.repository, listing.prs) : []; + } + /** Availability from current facts only; drives footers and key dispatch alike. */ actions(): ActionState[] { const worktree = this.selected(); @@ -141,7 +156,7 @@ export class WorktreeManagerController { this.navigationIntent() ? {id: 'navigate', available: true} : none('navigate', 'Path is not available'), this.current.snapshot && !stale ? {id: 'new', available: true} : none('new', 'Refresh first'), this.diffIntent() ? {id: 'diff', available: true} : none('diff', 'No HEAD or path'), - none('relatedPr', 'No pull request evidence yet'), + this.relatedPrs().length ? {id: 'relatedPr', available: true} : none('relatedPr', this.options.pullRequests?.() ? 'No pull request has this branch as its head' : 'No pull request evidence yet'), stale ? none('remove', 'List is stale; refresh first') : removal.available ? {id: 'remove', available: true} : none('remove', removal.reason), {id: 'refresh', available: true}, this.current.snapshot ? {id: 'search', available: true} : none('search', 'Nothing loaded'), @@ -188,7 +203,7 @@ export class WorktreeManagerController { cancelReview(): void { this.set({review: undefined}); } /** - * Keyboard model: ↑/↓ select, Enter navigate, n new, x preview removal, + * Keyboard model: ↑/↓ select, Enter navigate, n new, x preview removal, p related pull request, * r refresh, / search, Esc clears search, then review, then goes back. */ async handleKey(key: Key): Promise { @@ -222,6 +237,7 @@ export class WorktreeManagerController { if (value === 'r') { await this.refresh(); return {kind: 'changed'}; } if (value === 'n') return this.available('new') ? {kind: 'requestNewWorktree'} : {kind: 'none'}; if (value === 'x') { await this.previewRemoval(); return {kind: 'changed'}; } + if (value === 'p') { const related = this.relatedPrs()[0]; return related ? {kind: 'openPullRequest', pr: related.pr} : {kind: 'none'}; } if (value === '/' && this.available('search')) { this.set({searching: true}); return {kind: 'changed'}; } return {kind: 'none'}; } diff --git a/src/worktrees/relatedPr.ts b/src/worktrees/relatedPr.ts new file mode 100644 index 00000000..b05c46be --- /dev/null +++ b/src/worktrees/relatedPr.ts @@ -0,0 +1,26 @@ +import type {PrSummary} from '../githubWorkspace/model.js'; +import type {WorktreeRecord} from './model.js'; + +/** + * Pull requests related to a worktree, from evidence only: a PR whose head branch, in the same repository as the PR, + * is exactly the worktree's checked-out branch. A fork's PR with a branch of the same name is not related (its head + * lives elsewhere), a PR whose head repository GitHub did not report is not counted (fail closed), and nothing is + * inferred from titles, folder names or partial branch matches. Whether the worktree is at the PR's head commit is + * reported separately, because a local branch can be ahead of or behind what GitHub has. + */ + +export interface RelatedPr { + pr: PrSummary; + /** The worktree's HEAD is the PR's head commit; undefined when either is unknown. */ + atHead?: boolean; +} + +export function relatedPullRequests(worktree: WorktreeRecord, repository: string, prs: readonly PrSummary[]): RelatedPr[] { + if (!worktree.branch || worktree.detached || worktree.bare) return []; + const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + return prs + .filter(pr => same(`${pr.ref.owner}/${pr.ref.repo}`, repository) && pr.headRepository !== undefined && same(pr.headRepository, repository) && pr.headRef === worktree.branch) + .map(pr => ({pr, ...(pr.headSha && worktree.head ? {atHead: pr.headSha === worktree.head} : {})})) + // Open first, then most recently updated. + .sort((a, b) => Number(b.pr.state === 'open') - Number(a.pr.state === 'open') || (b.pr.updatedAt ?? '').localeCompare(a.pr.updatedAt ?? '')); +} diff --git a/tests/worktreeRelatedPr.test.ts b/tests/worktreeRelatedPr.test.ts new file mode 100644 index 00000000..cfb5bcd6 --- /dev/null +++ b/tests/worktreeRelatedPr.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mapSearch} from '../src/githubWorkspace/source.js'; +import {SEARCH_QUERY} from '../src/githubWorkspace/queries.js'; +import type {PrSummary} from '../src/githubWorkspace/model.js'; +import {relatedPullRequests} from '../src/worktrees/relatedPr.js'; +import type {WorktreeRecord} from '../src/worktrees/model.js'; + +const HEAD = 'a'.repeat(40); +const pr = (number: number, extra: Partial = {}): PrSummary => ({ref: {kind: 'pr', owner: 'raiseCatError', repo: 'notMyShell', number}, title: `PR ${number}`, + state: 'open', draft: false, headRef: 'feature/x', headRepository: 'raiseCatError/notMyShell', headSha: HEAD, reviewDecision: 'none', mergeable: 'unknown', checks: 'unknown', ...extra}); +const worktree = (extra: Partial = {}): WorktreeRecord => ({id: '/w', path: '/w', ref: 'refs/heads/feature/x', branch: 'feature/x', head: HEAD, detached: false, + bare: false, main: false, locked: false, prunable: false, pathState: 'present', status: {kind: 'clean'}, ...extra}); + +test('related pull requests: same-repository head branch only; forks, unknown heads and partial names are not evidence', () => { + const prs = [ + pr(1), + pr(2, {headRepository: 'someone/notMyShell'}), + pr(3, {headRepository: undefined}), + pr(4, {headRef: 'feature/x-2'}), + pr(5, {state: 'merged', updatedAt: '2026-10-11T00:00:00Z', headSha: 'b'.repeat(40)}), + pr(6, {ref: {kind: 'pr', owner: 'other', repo: 'repo', number: 6}, headRepository: 'other/repo'}), + ]; + const related = relatedPullRequests(worktree(), 'raiseCatError/notMyShell', prs); + assert.deepEqual(related.map(item => [item.pr.ref.number, item.atHead]), [[1, true], [5, false]], 'open first; the merged PR is related but its head is not this HEAD'); + assert.deepEqual(relatedPullRequests(worktree({detached: true, branch: undefined}), 'raiseCatError/notMyShell', prs), [], 'a detached HEAD has no branch to match'); + assert.deepEqual(relatedPullRequests(worktree(), 'RAISECATERROR/NOTMYSHELL', prs).length, 2, 'owner/name compare case-insensitively, as GitHub does'); + assert.equal(relatedPullRequests(worktree({head: undefined}), 'raiseCatError/notMyShell', [pr(1)])[0]!.atHead, undefined, 'unknown, not false'); +}); + +test('PR summaries carry the head repository and head commit from the search query', () => { + assert.match(SEARCH_QUERY, /headRefOid\s+headRepository \{ nameWithOwner \}/u); + const page = mapSearch({search: {issueCount: 1, pageInfo: {hasNextPage: false}, nodes: [{__typename: 'PullRequest', number: 7, title: 't', state: 'OPEN', headRefName: 'b', + headRefOid: HEAD, headRepository: {nameWithOwner: 'fork/notMyShell\u001b[31m'}, repository: {name: 'notMyShell', owner: {login: 'raiseCatError'}}}, {__typename: 'PullRequest', number: 8, title: 't', state: 'OPEN', + headRefOid: 'not-a-sha', repository: {name: 'notMyShell', owner: {login: 'raiseCatError'}}}]}}); + const [first, second] = page.items as PrSummary[]; + assert.equal(first!.headSha, HEAD); + assert.doesNotMatch(first!.headRepository!, /\u001b/u, 'hostile text is sanitized'); + assert.equal(second!.headSha, undefined, 'a malformed commit id is not kept'); + assert.equal(second!.headRepository, undefined); +});