From 8a40b76aa3317e85d1525bb6b506e2b6e2470892 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:52:06 +0530 Subject: [PATCH] Worktrees: related pull requests from evidence, never from names #337 with #338. The related-PR action was always "No pull request evidence yet". It is now available when the host passes its cached /github listing (no fetching in the manager), and `p` returns an openPullRequest intent. relatedPullRequests counts a PR only when its head branch, in the PR's own repository, is exactly the worktree's branch. A fork's PR with the same branch name, a PR whose head repository GitHub did not report, a detached worktree and partial branch names are not evidence. Whether the worktree is at the PR's head commit is reported separately. To tell fork PRs apart from summaries, the search query now asks for headRefOid and headRepository; PrSummary carries them (sanitized; a malformed commit id is dropped). Wiring the listing and the intent into TerminalApp is the remaining host step. --- docs/design/worktree-manager.md | 9 ++++++-- src/githubWorkspace/fixtures.ts | 1 + src/githubWorkspace/model.ts | 4 ++++ src/githubWorkspace/queries.ts | 3 ++- src/githubWorkspace/source.ts | 2 ++ src/worktrees/controller.ts | 22 +++++++++++++++--- src/worktrees/relatedPr.ts | 26 +++++++++++++++++++++ tests/worktreeRelatedPr.test.ts | 41 +++++++++++++++++++++++++++++++++ 8 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/worktrees/relatedPr.ts create mode 100644 tests/worktreeRelatedPr.test.ts 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); +});