Browse and review Azure DevOps pull requests from inside Claude Code via /prs

A pull request review plugin for Claude Code, built as a function-hooks mod. Stop tab-switching to check on your PR. /prs docks the whole review — diff, file tree, commits — right beside your Claude Code session, for Azure DevOps or GitHub, so "just checking the PR real quick" stops being a five-minute round trip through the browser.
Part of Ambareesha's Claude Code plugins. Add the marketplace once, then install prs from it.
Inside Claude Code:
/plugin marketplace add ambareeshav/claude-plugins
/plugin install prs@ambareeshav
Or, once the marketplace is added, run /plugin, open the ambareeshav marketplace, and install prs from the list.
If you're continuing a session, run /reload-plugins to turn it on, and /prs is available.
From the terminal:
claude plugin marketplace add ambareeshav/claude-plugins
claude plugin install prs@ambareeshav
/prs — auto-detects the PR for the current branch and opens it in a docked panel; falls back to a browsable list of open PRs if there isn't one./add-dir (or additionalDirectories in settings). Each repo has a bold header you can collapse, with Expand all / Collapse all above them and a divider between repos. A repo whose PRs can't load shows its error under its own header instead of failing the whole list./prs <id> — opens that PR (in the current repo) directly./prs --all — always shows the list, even when the current branch has a PR.dev.azure.com/visualstudio.com remotes, via the REST API + az) and GitHub (github.com remotes, via gh pr list/gh pr view — no separate token handling needed since gh manages its own auth). The provider is detected from the repo's origin remote automatically; everything past that point (diffing, the file tree, the panel) is identical for both.‹ back to PR list button, the PR title/description/status, and two tabs —+N/-M counts, and the selected file's diff (git's own hunks, boilerplate stripped).↗ open PR #… in browser link in the header opens the real PR page (dev.azure.com/.../pullrequest/<id> or github.com/.../pull/<id>) in your default browser.Not yet implemented: posting comments, replying to threads, voting (approve/reject/wait). These need write scopes and are a deliberate next slice, not an oversight.
az login for Azure DevOps repos, gh auth login for GitHub repos (checked at runtime; the mod never calls either for you).origin remote is either an Azure DevOps URL (dev.azure.com/<org>/<project>/_git/<repo>, the SSH form, or the legacy <org>.visualstudio.com form) or a GitHub URL (github.com/<owner>/<repo>, HTTPS or SSH).For local development instead of the marketplace install above: claude --plugin-dir /path/to/prs, or symlink this folder into ~/.claude/skills/prs to auto-load every session.
Pane ($.ui.open({ id, title, focus, closeOnEscape }), drawn from a ui.render hook matched on {component: 'Pane'}), not a CommandOutput row. CommandOutput always draws inline in the transcript and grows the chat log on every run — a Pane is what gives /diff-style behavior: docked beside the transcript, not scrolling past.command.run matcher alone does not create a slash command. It only handles one that's already registered. /prs has to be registered explicitly with $.command.register({ name, description, argumentHint }) in a session.start hook — found by comparing against a working mod (tetris) that does this and has no commands/*.md file. Without it, Claude Code falls back to treating a commands/<name>.md file (if one exists) as a markdown "Skill" — running its text as instructions for the model to improvise with Bash — rather than ever reaching the hook. This plugin has no commands/ folder at all; the hook is the only source of truth for /prs.RepoContext is a {provider: 'ado', ado} | {provider: 'github', github} union resolved once from the origin remote (lib/git.ts); only listPullRequests/getPullRequest dispatch on it. Diffing, the file tree, and the panel never look at the provider — they only ever see branch names, which is why GitHub support didn't touch gitDiffFiles, gitDiffForFile, or any rendering code at all. GitHub's calls go through gh pr list/gh pr view --json ..., not raw REST — gh already manages its own auth, so there's no token-caching code to mirror getAdoAccessToken.git log, not a provider API either. git log --reverse origin/<target>..origin/<source> lists exactly the commits the PR adds, in the order they'd apply — same refs already fetched for diffing, so no gh pr view --json commits / ADO commits endpoint needed, and it behaves identically for both providers.git diff, not the ADO REST API. Both branches are fetched from origin, so git diff origin/<target>...origin/<source> gives --numstat (per-file +/- counts), --name-status (the file list), and each file's unified diff for free. The unified diff's diff --git/index/---/+++ boilerplate is stripped (lib/diff.ts), keeping only the @@ hunks. Earlier drafts fetched raw file content via ADO's items endpoint and hand-rolled an LCS line diff — unnecessary once the same refs were being fetched anyway, and git's own diff algorithm is better.git fetch origin <source> <target> (which, like any fetch, updates the origin/* refs and stores their commits in .git) and then only reads: git diff and git log on those refs. It never checks out, switches or creates a branch, makes a worktree, or touches your working tree.lib/tree.ts, pure): changed files are grouped into a directory tree, single-child directory chains are compressed onto one line (src/components/admin), and each file is a plain Button (no bracket chrome) with colored +/- counts as separate Text elements alongside it (a Button's label is plain text only, so the counts can't be colored inside the button).$ call lives in register.tsx as a top-level function. claude plugin validate enforces this: a $ call is only tracked when it's textually inside the hooks module, in a function declared at the file's top level — not through an imported helper, and not through a function nested inside another. Only pure logic (parseAdoRemote, branchName, toPullRequestSummary, URL building, the diff cleaner, the tree builder) lives in hooks/lib/.ui.press hook. A Button's onPress runs directly "in the plugin's own environment" per the generated types — it's a plain closure over $, no separate event-dispatch/hotkey-matching layer needed. State mutation + $.ui.invalidate('ui.render') inside onPress is enough to get a redraw.Markdown elements cap at 10000 characters; a large file's diff is truncated with a note rather than silently failing to render.Box's gap prop produced no visible blank row between siblings in practice, and a blank-row spacer read as too subtle anyway — a Divider = () => <Text dimColor>{'─'.repeat(width)}</Text> sized to the pane's own e.props.bodyColumns reads as an actual TUI section rule. The header is split into separate title/branch/description Markdown blocks with a Divider between each, rather than one combined block.https: link in a Markdown block, not a shell-out. No API in the generated types opens a URL/browser directly — but per the docs, a link whose scheme is https:/http:/file: is natively clickable and opens via the surface's own default behavior (a link with any other scheme just draws as inert text). That's simpler and more portable than shelling out to open/xdg-open/start, which would need OS detection this mod otherwise has no reason to do. The URL itself (lib/git.ts's prWebUrl) is pure and provider-aware, same pattern as everything else here.claude plugin validate . — static check: confirms the manifest, that every $ call is reachable the way the engine requires, and lists exactly what the plugin hooks and calls.claude plugin test . — runs hooks/register.test.ts against the real engine (mocked process.run/http.fetch/store.*), including a $.ui.mount test that mounts the Pane, presses a file button, and checks the diff panel re-renders with the boilerplate stripped and the right +/- counts shown..claude/types/claude-code.d.ts (not committed — regenerate with /plugin-types in a session) is what all of the above and the code itself were checked against, for Claude Code 2.1.278. Regenerate it after an engine update rather than trusting a stale copy — this is an early-access API that can change between releases.claude plugin validate/claude plugin test, and manually against a real Azure DevOps org — but this is early-access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) and the API can shift between releases./prs stays out of your working tree.hooks/register.tsx 801 lines1import { parseRemote, branchName, prWebUrl } from './lib/git';
2import type { AdoRepoContext, GithubRepoContext, RepoContext } from './lib/git';
3import { adoBaseUrl, toPullRequestSummary, isSignInPage, tenantFromSignIn, ADO_API_VERSION, ADO_RESOURCE_ID } from './lib/ado';
4import type { PullRequestSummary, PullRequestDetail } from './lib/ado';
5import { buildFileTreeRows } from './lib/tree';
6import type { FileChange } from './lib/tree';
7import { cleanUnifiedDiff } from './lib/diff';
8
9// Checked against the real generated claude-code.d.ts (via /plugin-types) —
10// see README.md for what that changed vs. the first draft. Every `$` call
11// stays a top-level function declaration in this file on purpose: `claude
12// plugin validate` requires `$` to be followed only through functions
13// declared at the top of the hooks module, never through an imported helper
14// or a function nested inside another.
15
16const TOKEN_STORE_KEY = 'prs:ado-token';
17const TENANT_STORE_KEY = 'prs:ado-tenant';
18const DIRS_STORE_KEY = 'prs:dirs';
19const PANE_ID = 'prs';
20const MAX_DIFF_CHARS = 9000; // Markdown elements cap at 10000 chars
21
22type RepoGroup = { label: string; root: string; ctx: RepoContext };
23
24type CommitInfo = { sha: string; subject: string; body: string };
25
26type ReviewTab = 'files' | 'commits';
27
28type BrowserGroup = { group: RepoGroup; prs: PullRequestSummary[]; error?: string };
29
30type ViewState =
31 | { mode: 'error'; message: string }
32 | { mode: 'browser'; groups: BrowserGroup[] }
33 | {
34 mode: 'review';
35 group: RepoGroup;
36 detail: PullRequestDetail;
37 files: FileChange[];
38 commits: CommitInfo[];
39 activeTab: ReviewTab;
40 expandedShas: Set<string>;
41 selectedPath: string | null;
42 diffCache: Map<string, string>;
43 loadingDiff: boolean;
44 fetchMessage: string | null;
45 };
46
47let currentView: ViewState | null = null;
48const repoRegistry = new Map<string, RepoGroup>();
49// Repo roots collapsed in the browser; module state, so a reload expands all.
50const collapsedRoots = new Set<string>();
51
52function repoLabel(ctx: RepoContext): string {
53 return ctx.provider === 'github' ? `${ctx.github.owner}/${ctx.github.repo}` : `${ctx.ado.project}/${ctx.ado.repo}`;
54}
55
56function parseArgs(e: any): string {
57 const raw = e.args ?? '';
58 return typeof raw === 'string' ? raw.trim() : '';
59}
60
61function ttlMinutesFrom(options: any): number {
62 const parsed = Number(options?.tokenTtlMinutes ?? '45');
63 return Number.isFinite(parsed) && parsed > 0 ? parsed : 45;
64}
65
66function summaryText(view: ViewState | null): string {
67 if (!view) return '/prs: nothing to show';
68 if (view.mode === 'error') return `/prs: ${view.message}`;
69 if (view.mode === 'browser') {
70 const total = view.groups.reduce((n, g) => n + g.prs.length, 0);
71 return `/prs: ${total} open PR(s) across ${view.groups.length} repo(s)`;
72 }
73 return `/prs ${view.detail.pullRequestId}: ${view.detail.title}`;
74}
75
76// --- process / git ----------------------------------------------------
77
78async function run(
79 $: any,
80 argv: string[],
81 cwd?: string,
82 env?: Record<string, string>,
83): Promise<{ stdout: string; stderr: string; exitCode: number }> {
84 const init = { ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) };
85 return $.process.run(argv, Object.keys(init).length ? init : undefined);
86}
87
88async function resolveRepoContext($: any, dir: string): Promise<{ root: string; ctx: RepoContext } | null> {
89 const toplevel = await run($, ['git', 'rev-parse', '--show-toplevel'], dir);
90 if (toplevel.exitCode !== 0) return null;
91 const root = toplevel.stdout.trim();
92 const remote = await run($, ['git', 'remote', 'get-url', 'origin'], root);
93 if (remote.exitCode !== 0) return null;
94 const ctx = parseRemote(remote.stdout.trim());
95 if (!ctx) return null;
96 return { root, ctx };
97}
98
99async function sessionCwd($: any): Promise<string | undefined> {
100 return $.session.cwd().catch(() => undefined);
101}
102
103async function ensureRepoGroup($: any, dir?: string): Promise<RepoGroup | null> {
104 const where = dir ?? (await sessionCwd($));
105 if (!where) return null;
106 const resolved = await resolveRepoContext($, where);
107 if (!resolved) return null;
108 const existing = repoRegistry.get(resolved.root);
109 if (existing) return existing;
110 const group: RepoGroup = { label: repoLabel(resolved.ctx), root: resolved.root, ctx: resolved.ctx };
111 repoRegistry.set(resolved.root, group);
112 return group;
113}
114
115async function currentBranch($: any, cwd: string): Promise<string | null> {
116 const res = await run($, ['git', 'branch', '--show-current'], cwd);
117 const name = res.stdout.trim();
118 return res.exitCode === 0 && name ? name : null;
119}
120
121// The pane runs git with no terminal, so git can't ask for a password. For ADO,
122// hand git the same az token the REST calls use, through env-only git config
123// (not argv, so it stays out of the process list).
124async function fetchBranches(
125 $: any,
126 group: RepoGroup,
127 ttlMinutes: number,
128 branches: string[],
129): Promise<{ ok: boolean; message?: string }> {
130 const env: Record<string, string> = { GIT_TERMINAL_PROMPT: '0' };
131 if (group.ctx.provider === 'ado') {
132 const tenant = await resolveAdoTenant($, group.ctx.ado.org);
133 const token = await getAdoAccessToken($, ttlMinutes, tenant);
134 Object.assign(env, {
135 GIT_CONFIG_COUNT: '1',
136 GIT_CONFIG_KEY_0: 'http.extraHeader',
137 GIT_CONFIG_VALUE_0: `Authorization: Bearer ${token}`,
138 });
139 }
140 const res = await run($, ['git', 'fetch', 'origin', ...branches], group.root, env);
141 return res.exitCode === 0 ? { ok: true } : { ok: false, message: res.stderr.trim().split('\n').pop() };
142}
143
144async function gitDiffFiles($: any, root: string, target: string, source: string): Promise<FileChange[]> {
145 const range = `origin/${target}...origin/${source}`;
146 const [statusRes, numstatRes] = await Promise.all([
147 run($, ['git', 'diff', '--name-status', range], root),
148 run($, ['git', 'diff', '--numstat', range], root),
149 ]);
150 if (statusRes.exitCode !== 0) return [];
151
152 const counts = new Map<string, { added: number | null; deleted: number | null }>();
153 if (numstatRes.exitCode === 0) {
154 for (const line of numstatRes.stdout.split('\n')) {
155 const trimmed = line.trim();
156 if (!trimmed) continue;
157 const [addedStr, deletedStr, ...rest] = trimmed.split('\t');
158 counts.set(rest.join('\t'), {
159 added: addedStr === '-' ? null : Number(addedStr),
160 deleted: deletedStr === '-' ? null : Number(deletedStr),
161 });
162 }
163 }
164
165 return statusRes.stdout
166 .split('\n')
167 .map((l) => l.trim())
168 .filter(Boolean)
169 .map((line): FileChange => {
170 const [status, ...rest] = line.split('\t');
171 const path = rest.join('\t');
172 const count = counts.get(path);
173 return { path, status, added: count?.added ?? null, deleted: count?.deleted ?? null };
174 });
175}
176
177async function gitDiffForFile($: any, root: string, target: string, source: string, path: string): Promise<string> {
178 const res = await run($, ['git', 'diff', `origin/${target}...origin/${source}`, '--', path], root);
179 return cleanUnifiedDiff(res.stdout);
180}
181
182const RECORD_SEP = '\x1e';
183const FIELD_SEP = '\x1f';
184
185/** Commits unique to the source branch (oldest first), the same way `gh`/ADO's
186 * own "Commits" tab lists a PR — from git log, not another provider API call. */
187async function gitCommitsBetween($: any, root: string, target: string, source: string): Promise<CommitInfo[]> {
188 const format = `%H${FIELD_SEP}%s${FIELD_SEP}%b${RECORD_SEP}`;
189 const res = await run($, ['git', 'log', '--reverse', `--format=${format}`, `origin/${target}..origin/${source}`], root);
190 if (res.exitCode !== 0) return [];
191 return res.stdout
192 .split(RECORD_SEP)
193 .map((rec) => rec.trim())
194 .filter(Boolean)
195 .map((rec): CommitInfo => {
196 const [sha, subject, body] = rec.split(FIELD_SEP);
197 return { sha: sha ?? '', subject: subject ?? '', body: (body ?? '').trim() };
198 });
199}
200
201// --- Azure DevOps REST (PR metadata only; diffs come from git) --------
202
203// The org's Entra tenant, found by asking ADO anonymously. az's default account
204// may sit in a different tenant, and a token from there gets a sign-in page.
205async function resolveAdoTenant($: any, org: string): Promise<string | undefined> {
206 const key = `${TENANT_STORE_KEY}:${org}`;
207 const cached = (await $.store.get(key)) as string | undefined;
208 if (cached) return cached;
209
210 const res = await $.http.fetch(`https://dev.azure.com/${encodeURIComponent(org)}/_apis/connectionData`);
211 const tenant = tenantFromSignIn(res);
212 if (tenant) await $.store.set(key, tenant);
213 return tenant;
214}
215
216async function getAdoAccessToken($: any, ttlMinutes: number, tenant: string | undefined): Promise<string> {
217 const key = `${TOKEN_STORE_KEY}:${tenant ?? 'default'}`;
218 const cached = (await $.store.get(key)) as { token: string; expiresAt: number } | undefined;
219 const now = Date.now();
220 if (cached && cached.expiresAt > now) return cached.token;
221
222 const loginCheck = await run($, ['az', 'account', 'show']);
223 if (loginCheck.exitCode !== 0) {
224 throw new Error('Not logged into Azure CLI. Run `az login` in a terminal, then retry /prs.');
225 }
226
227 const tokenRes = await run($, [
228 'az', 'account', 'get-access-token',
229 '--resource', ADO_RESOURCE_ID,
230 ...(tenant ? ['--tenant', tenant] : []),
231 '--query', 'accessToken',
232 '-o', 'tsv',
233 ]);
234 const token = tokenRes.stdout.trim();
235 if (tokenRes.exitCode !== 0 || !token) {
236 const hint = tenant ? ` Run \`az login --tenant ${tenant}\` in a terminal, then retry /prs.` : '';
237 throw new Error(`Failed to get an Azure DevOps access token via az CLI: ${tokenRes.stderr.trim()}${hint}`);
238 }
239
240 await $.store.set(key, { token, expiresAt: now + ttlMinutes * 60_000 });
241 return token;
242}
243
244async function adoJson($: any, ttlMinutes: number, ctx: AdoRepoContext, url: string): Promise<any> {
245 const tenant = await resolveAdoTenant($, ctx.org);
246 const token = await getAdoAccessToken($, ttlMinutes, tenant);
247 const res = await $.http.fetch(url, { headers: { Authorization: `Bearer ${token}` } });
248 if (!res.ok) {
249 throw new Error(`Azure DevOps API ${res.status} on ${url.split('?')[0]}: ${(res.text ?? '').slice(0, 300)}`);
250 }
251 if (isSignInPage(res)) {
252 const login = tenant ? `az login --tenant ${tenant}` : 'az login';
253 throw new Error(
254 `Azure DevOps sent a sign-in page instead of data for org "${ctx.org}", so the az CLI account can't access it. ` +
255 `Run \`${login}\` with an account in that org, then retry /prs.`,
256 );
257 }
258 return JSON.parse(res.text);
259}
260
261async function listAdoPullRequests($: any, ctx: AdoRepoContext, ttlMinutes: number): Promise<PullRequestSummary[]> {
262 const url = `${adoBaseUrl(ctx)}/pullrequests?searchCriteria.status=active&api-version=${ADO_API_VERSION}`;
263 const body = await adoJson($, ttlMinutes, ctx, url);
264 return (body.value ?? []).map(toPullRequestSummary);
265}
266
267async function getAdoPullRequest($: any, ctx: AdoRepoContext, ttlMinutes: number, id: number): Promise<PullRequestDetail> {
268 const url = `${adoBaseUrl(ctx)}/pullrequests/${id}?api-version=${ADO_API_VERSION}`;
269 const pr = await adoJson($, ttlMinutes, ctx, url);
270 return { ...toPullRequestSummary(pr), description: pr.description ?? '' };
271}
272
273// --- GitHub (via the already-authenticated `gh` CLI; no token plumbing needed) --
274
275function toGithubSummary(pr: any): PullRequestSummary {
276 return {
277 pullRequestId: pr.number,
278 title: pr.title,
279 status: (pr.state ?? 'open').toLowerCase(),
280 createdBy: pr.author?.login ?? 'unknown',
281 sourceRefName: `refs/heads/${pr.headRefName}`,
282 targetRefName: `refs/heads/${pr.baseRefName}`,
283 creationDate: pr.createdAt,
284 isDraft: !!pr.isDraft,
285 };
286}
287
288async function listGithubPullRequests($: any, gh: GithubRepoContext): Promise<PullRequestSummary[]> {
289 const res = await run($, [
290 'gh', 'pr', 'list',
291 '--repo', `${gh.owner}/${gh.repo}`,
292 '--state', 'open',
293 '--json', 'number,title,author,headRefName,baseRefName,createdAt,isDraft',
294 ]);
295 if (res.exitCode !== 0) throw new Error(`gh pr list failed: ${(res.stderr || res.stdout).trim()}`);
296 return (JSON.parse(res.stdout) as any[]).map(toGithubSummary);
297}
298
299async function getGithubPullRequest($: any, gh: GithubRepoContext, id: number): Promise<PullRequestDetail> {
300 const res = await run($, [
301 'gh', 'pr', 'view', String(id),
302 '--repo', `${gh.owner}/${gh.repo}`,
303 '--json', 'number,title,author,headRefName,baseRefName,createdAt,isDraft,state,body',
304 ]);
305 if (res.exitCode !== 0) throw new Error(`gh pr view failed: ${(res.stderr || res.stdout).trim()}`);
306 const pr = JSON.parse(res.stdout);
307 return { ...toGithubSummary(pr), description: pr.body ?? '' };
308}
309
310// --- the session's repos -------------------------------------------------
311
312// Directories the session works in beyond its cwd. /add-dir raises
313// DirectoryAdded, kept per session in the store so a reload keeps them; the
314// transcript's /add-dir lines and the settings' additionalDirectories cover
315// what was added before this module loaded.
316async function rememberDir($: any, dir: string): Promise<void> {
317 const id = await $.session.id().catch(() => 'unknown');
318 const key = `${DIRS_STORE_KEY}:${id}`;
319 const dirs = ((await $.store.get(key)) as string[] | undefined) ?? [];
320 if (!dirs.includes(dir)) await $.store.set(key, [...dirs, dir]);
321}
322
323async function storedDirs($: any): Promise<string[]> {
324 const id = await $.session.id().catch(() => 'unknown');
325 return ((await $.store.get(`${DIRS_STORE_KEY}:${id}`).catch(() => undefined)) as string[] | undefined) ?? [];
326}
327
328const ADD_DIR_IN_TRANSCRIPT = /<command-name>\/add-dir<\/command-name>[\s\S]*?<command-args>([^<]+)<\/command-args>/g;
329
330async function transcriptDirs($: any): Promise<string[]> {
331 const messages = ((await $.session.messages().catch(() => [])) ?? []) as { role: string; text: string }[];
332 const dirs: string[] = [];
333 for (const m of messages) {
334 if (m.role !== 'user' || !m.text?.includes('/add-dir')) continue;
335 for (const match of m.text.matchAll(ADD_DIR_IN_TRANSCRIPT)) {
336 const dir = match[1]?.trim();
337 if (dir) dirs.push(dir);
338 }
339 }
340 return dirs;
341}
342
343async function settingsDirs($: any): Promise<string[]> {
344 const settings = (await $.settings.read().catch(() => ({}))) as any;
345 const dirs = settings?.permissions?.additionalDirectories;
346 return Array.isArray(dirs) ? dirs.filter((d: unknown): d is string => typeof d === 'string') : [];
347}
348
349// Every GitHub or ADO repo the session can see, the cwd's first.
350async function sessionRepoGroups($: any): Promise<RepoGroup[]> {
351 const cwd = await sessionCwd($);
352 const extra = [...(await storedDirs($)), ...(await transcriptDirs($)), ...(await settingsDirs($))];
353 const dirs = [...new Set([...(cwd ? [cwd] : []), ...extra.map((d) => d.replace(/\/+$/, '') || '/')])];
354 const groups: RepoGroup[] = [];
355 for (const dir of dirs) {
356 const group = await ensureRepoGroup($, dir);
357 if (group && !groups.some((g) => g.root === group.root)) groups.push(group);
358 }
359 for (const group of repoRegistry.values()) {
360 if (!groups.some((g) => g.root === group.root)) groups.push(group);
361 }
362 return groups;
363}
364
365async function loadBrowser($: any, ttlMinutes: number, groups: RepoGroup[]): Promise<ViewState> {
366 const loaded: BrowserGroup[] = [];
367 for (const group of groups) {
368 try {
369 loaded.push({ group, prs: await listPullRequests($, group, ttlMinutes) });
370 } catch (err: any) {
371 loaded.push({ group, prs: [], error: err?.message ?? String(err) });
372 }
373 }
374 return { mode: 'browser', groups: loaded };
375}
376
377// --- provider dispatch ------------------------------------------------
378
379async function listPullRequests($: any, group: RepoGroup, ttlMinutes: number): Promise<PullRequestSummary[]> {
380 return group.ctx.provider === 'github'
381 ? listGithubPullRequests($, group.ctx.github)
382 : listAdoPullRequests($, group.ctx.ado, ttlMinutes);
383}
384
385async function getPullRequest($: any, group: RepoGroup, ttlMinutes: number, id: number): Promise<PullRequestDetail> {
386 return group.ctx.provider === 'github'
387 ? getGithubPullRequest($, group.ctx.github, id)
388 : getAdoPullRequest($, group.ctx.ado, ttlMinutes, id);
389}
390
391// --- view construction --------------------------------------------------
392
393async function openReview($: any, ttlMinutes: number, group: RepoGroup, prId: number): Promise<void> {
394 const detail = await getPullRequest($, group, ttlMinutes, prId);
395 const source = branchName(detail.sourceRefName);
396 const target = branchName(detail.targetRefName);
397
398 const fetch = await fetchBranches($, group, ttlMinutes, [source, target]);
399 const fetched = fetch.ok;
400 const [files, commits] = fetched
401 ? await Promise.all([gitDiffFiles($, group.root, target, source), gitCommitsBetween($, group.root, target, source)])
402 : [[], []];
403
404 // Read-only on the repo: no checkout, branch or worktree. The files and
405 // commits come from the fetched origin/* refs, never the working tree.
406 const fetchMessage = fetched
407 ? null
408 : `couldn't fetch ${source} and ${target} from origin${fetch.message ? `: ${fetch.message}` : ''}`;
409
410 const view: ViewState = {
411 mode: 'review',
412 group,
413 detail,
414 files,
415 commits,
416 activeTab: 'files',
417 expandedShas: new Set(),
418 selectedPath: files[0]?.path ?? null,
419 diffCache: new Map(),
420 loadingDiff: false,
421 fetchMessage,
422 };
423 currentView = view;
424
425 if (view.selectedPath) await loadDiffForSelected($, view);
426}
427
428async function goBackToBrowser($: any, ttlMinutes: number): Promise<void> {
429 currentView = await loadBrowser($, ttlMinutes, await sessionRepoGroups($));
430}
431
432async function loadDiffForSelected($: any, view: Extract<ViewState, { mode: 'review' }>): Promise<void> {
433 if (!view.selectedPath || view.diffCache.has(view.selectedPath)) return;
434 view.loadingDiff = true;
435 try {
436 const diffText = await gitDiffForFile(
437 $,
438 view.group.root,
439 branchName(view.detail.targetRefName),
440 branchName(view.detail.sourceRefName),
441 view.selectedPath,
442 );
443 view.diffCache.set(view.selectedPath, diffText);
444 } finally {
445 view.loadingDiff = false;
446 }
447}
448
449// --- the PR list's look ---------------------------------------------------
450// Raw hex, not theme keys, so it reads the same in either terminal theme. The
451// background is a vertical gradient, ink → dusty plum, painted one solid row
452// at a time (there's no gradient prop); the pastels on top stay muted.
453const BG_FROM = [0x17, 0x19, 0x21];
454const BG_TO = [0x3a, 0x30, 0x42];
455const REPO_TINTS = ['#a9c4a4', '#b9aedc', '#e2b9a0', '#9fbfd6', '#d6a9b8']; // sage, lavender, peach, sky, rose
456const RULE_COLOR = '#5d566e';
457const HAIRLINE_COLOR = '#4a4558';
458const ERROR_COLOR = '#d99a9a';
459const ROW_HOVER = { backgroundColor: '#2d2a3a', color: '#e8e3f2' } as const;
460
461function gradientAt(row: number, rows: number): string {
462 const t = Math.pow(Math.min(1, Math.max(0, row / Math.max(1, rows - 1))), 1.3);
463 return '#' + BG_FROM.map((from, i) => Math.round(from + (BG_TO[i]! - from) * t).toString(16).padStart(2, '0')).join('');
464}
465
466// one pane line, cut to fit: a row that wrapped would throw the gradient off
467function fit(label: string, columns: number): string {
468 const room = Math.max(8, columns - 2);
469 return label.length > room ? label.slice(0, room - 1) + '…' : label;
470}
471
472function renderBrowser($: any, e: any, ttlMinutes: number, view: Extract<ViewState, { mode: 'browser' }>) {
473 const { Box, Text, Button } = $.ui.resolve(e);
474 const columns = Math.max(20, (e.props as any)?.bodyColumns ?? 60);
475 const bodyRows = Math.max(1, (e.props as any)?.scroll?.bodyRows ?? 30);
476
477 // every entry is exactly one line; its background comes from its position
478 const rows: { key: string; hover?: boolean; draw: () => any }[] = [];
479 if (view.groups.length > 1) {
480 rows.push({
481 key: 'row:toolbar',
482 draw: () => (
483 <Box flexDirection="row" columnGap={2}>
484 <Button
485 key="expand-all"
486 plain
487 label="⊞ Expand all"
488 dimColor
489 onPress={() => {
490 collapsedRoots.clear();
491 $.ui.invalidate('ui.render');
492 }}
493 />
494 <Button
495 key="collapse-all"
496 plain
497 label="⊟ Collapse all"
498 dimColor
499 onPress={() => {
500 for (const g of view.groups) collapsedRoots.add(g.group.root);
501 $.ui.invalidate('ui.render');
502 }}
503 />
504 </Box>
505 ),
506 });
507 }
508 view.groups.forEach(({ group, prs, error }, i) => {
509 const tint = REPO_TINTS[i % REPO_TINTS.length]!;
510 const collapsed = collapsedRoots.has(group.root);
511 if (rows.length > 0) {
512 rows.push({ key: `row:rule:${group.root}`, draw: () => <Text color={RULE_COLOR}>{'─'.repeat(columns - 2)}</Text> });
513 }
514 rows.push({
515 key: `row:head:${group.root}`,
516 draw: () => (
517 <Box flexDirection="row" columnGap={1}>
518 <Button
519 key={`toggle:${group.root}`}
520 plain
521 label={collapsed ? '▸' : '▾'}
522 onPress={() => {
523 if (collapsed) collapsedRoots.delete(group.root);
524 else collapsedRoots.add(group.root);
525 $.ui.invalidate('ui.render');
526 }}
527 />
528 <Text bold color={tint}>{group.label}</Text>
529 <Text dimColor>{error ? '· error' : `· ${prs.length} open`}</Text>
530 </Box>
531 ),
532 });
533 if (collapsed) return;
534 if (error) {
535 rows.push({ key: `row:error:${group.root}`, draw: () => <Text color={ERROR_COLOR}>{fit(` ${error}`, columns)}</Text> });
536 } else if (prs.length === 0) {
537 rows.push({ key: `row:empty:${group.root}`, draw: () => <Text dimColor>{' no active pull requests'}</Text> });
538 }
539 prs.forEach((pr, j) => {
540 if (j > 0) {
541 const width = Math.max(6, Math.floor(columns * 0.3));
542 const pad = ' '.repeat(Math.max(0, Math.floor((columns - 2 - width) / 2)));
543 rows.push({
544 key: `row:hair:${group.root}:${pr.pullRequestId}`,
545 draw: () => <Text color={HAIRLINE_COLOR}>{pad + '┄'.repeat(width)}</Text>,
546 });
547 }
548 rows.push({
549 key: `row:pr:${group.root}:${pr.pullRequestId}`,
550 hover: true,
551 draw: () => (
552 <Button
553 key={`open:${group.root}:${pr.pullRequestId}`}
554 plain
555 hover={ROW_HOVER}
556 label={fit(` #${pr.pullRequestId} ${pr.title} — ${pr.createdBy}${pr.isDraft ? ' (draft)' : ''}`, columns)}
557 onPress={() => {
558 openReview($, ttlMinutes, group, pr.pullRequestId).then(() => $.ui.invalidate('ui.render'));
559 }}
560 />
561 ),
562 });
563 });
564 });
565
566 // filler rows carry the gradient to the pane's bottom under a short list
567 const total = Math.max(bodyRows, rows.length);
568 for (let n = rows.length; n < total; n++) rows.push({ key: `row:fill:${n}`, draw: () => <Text> </Text> });
569
570 return (
571 // sized to the pane: a Box only paints its background over the cells it occupies
572 <Box flexDirection="column" width={columns}>
573 {rows.map((row, n) => (
574 <Box
575 key={row.key}
576 height={1}
577 paddingX={1}
578 backgroundColor={gradientAt(n, total)}
579 {...(row.hover ? { hover: { backgroundColor: ROW_HOVER.backgroundColor } } : {})}
580 >
581 {row.draw()}
582 </Box>
583 ))}
584 </Box>
585 );
586}
587
588function renderView($: any, e: any, options: any, view: ViewState) {
589 const { Box, Text, Markdown, Button } = $.ui.resolve(e);
590 const ttlMinutes = ttlMinutesFrom(options);
591 // A visible section rule — `gap` on Box produced no visible blank row here.
592 const dividerWidth = Math.max(1, (e.props as any)?.bodyColumns ?? 60);
593 const Divider = () => <Text dimColor>{'─'.repeat(dividerWidth)}</Text>;
594
595 if (view.mode === 'error') {
596 return <Markdown text={`**/prs error:** ${view.message}`} />;
597 }
598
599 if (view.mode === 'browser') {
600 return renderBrowser($, e, ttlMinutes, view);
601 }
602
603 const tabButton = (tab: ReviewTab, label: string) => (
604 <Button
605 key={`tab:${tab}`}
606 plain
607 label={view.activeTab === tab ? `● ${label}` : label}
608 onPress={() => {
609 view.activeTab = tab;
610 $.ui.invalidate('ui.render');
611 }}
612 />
613 );
614
615 let content: any;
616 if (view.activeTab === 'commits') {
617 content = (
618 <Box flexDirection="column">
619 {view.commits.length === 0 && <Markdown text="_no commits on this branch_" />}
620 {view.commits.map((c, i) => {
621 const expanded = view.expandedShas.has(c.sha);
622 return (
623 <Box key={`commit:${c.sha}`} flexDirection="column">
624 {i > 0 && <Divider />}
625 <Button
626 key={`commit:${c.sha}`}
627 plain
628 label={`${c.sha.slice(0, 7)} ${c.subject}`}
629 onPress={() => {
630 if (expanded) view.expandedShas.delete(c.sha);
631 else view.expandedShas.add(c.sha);
632 $.ui.invalidate('ui.render');
633 }}
634 />
635 {expanded && (c.body ? <Markdown text={c.body} /> : <Text dimColor> (no description)</Text>)}
636 </Box>
637 );
638 })}
639 </Box>
640 );
641 } else {
642 const diffBody = view.selectedPath ? view.diffCache.get(view.selectedPath) : undefined;
643 const truncated = !!diffBody && diffBody.length > MAX_DIFF_CHARS;
644 const diffText = diffBody
645 ? '```diff\n' + diffBody.slice(0, MAX_DIFF_CHARS) + (truncated ? '\n… (truncated)' : '') + '\n```'
646 : view.loadingDiff
647 ? '_loading diff…_'
648 : '_select a file_';
649
650 const treeRows = buildFileTreeRows(view.files);
651
652 content = (
653 <Box flexDirection="column">
654 <Box flexDirection="column">
655 {treeRows.map((row) =>
656 row.kind === 'dir' ? (
657 <Text key={`dir:${row.depth}:${row.name}`} dimColor>
658 {' '.repeat(row.depth) + row.name + '/'}
659 </Text>
660 ) : (
661 <Box key={`file:${row.file.path}`} flexDirection="row" columnGap={1}>
662 <Button
663 key={`file:${row.file.path}`}
664 plain
665 label={
666 ' '.repeat(row.depth) +
667 row.name +
668 (row.file.path === view.selectedPath ? ' •' : '')
669 }
670 onPress={() => {
671 view.selectedPath = row.file.path;
672 loadDiffForSelected($, view).then(() => $.ui.invalidate('ui.render'));
673 }}
674 />
675 {row.file.added !== null && <Text color="green">+{row.file.added}</Text>}
676 {row.file.deleted !== null && <Text color="red">-{row.file.deleted}</Text>}
677 {row.file.added === null && <Text dimColor>({row.file.status})</Text>}
678 </Box>
679 ),
680 )}
681 </Box>
682 <Divider />
683 <Markdown text={diffText} />
684 </Box>
685 );
686 }
687
688 return (
689 <Box flexDirection="column">
690 <Button
691 key="back"
692 plain
693 label="‹ back to PR list"
694 onPress={() => {
695 goBackToBrowser($, ttlMinutes).then(() => $.ui.invalidate('ui.render'));
696 }}
697 />
698 <Divider />
699 <Markdown text={`#${view.detail.pullRequestId} ${view.detail.title}`} />
700 <Divider />
701 <Markdown
702 text={`${branchName(view.detail.sourceRefName)} → ${branchName(view.detail.targetRefName)} · ${view.detail.createdBy} · ${view.detail.status}${view.detail.isDraft ? ' · draft' : ''}`}
703 />
704 <Markdown text={`[↗ open PR #${view.detail.pullRequestId} in browser](${prWebUrl(view.group.ctx, view.detail.pullRequestId)})`} />
705 {view.fetchMessage && (
706 <>
707 <Divider />
708 <Text color="yellow">{view.fetchMessage}</Text>
709 </>
710 )}
711 <Divider />
712 <Markdown text={view.detail.description || '_no description_'} />
713 <Divider />
714 <Box flexDirection="row" columnGap={2}>
715 {tabButton('files', 'Files')}
716 {tabButton('commits', `Commits (${view.commits.length})`)}
717 </Box>
718 <Divider />
719 {content}
720 </Box>
721 );
722}
723
724async function handleCommandRun($: any, e: any, options: any): Promise<{ text: string }> {
725 const args = parseArgs(e);
726 const ttlMinutes = ttlMinutesFrom(options);
727
728 try {
729 if (args === '--all') {
730 const groups = await sessionRepoGroups($);
731 currentView =
732 groups.length === 0
733 ? { mode: 'error', message: 'no GitHub or Azure DevOps repos in this session — run /prs from inside one, or /add-dir one' }
734 : await loadBrowser($, ttlMinutes, groups);
735 } else if (/^\d+$/.test(args)) {
736 const group = await ensureRepoGroup($);
737 if (!group) {
738 currentView = { mode: 'error', message: "current directory isn't a GitHub or Azure DevOps repo (no parseable origin remote)" };
739 } else {
740 await openReview($, ttlMinutes, group, Number(args));
741 }
742 } else if (args.length === 0) {
743 const group = await ensureRepoGroup($);
744 if (!group) {
745 currentView = { mode: 'error', message: "current directory isn't a GitHub or Azure DevOps repo (no parseable origin remote)" };
746 } else {
747 const branch = await currentBranch($, group.root);
748 const prs = await listPullRequests($, group, ttlMinutes);
749 const match = branch ? prs.find((pr) => branchName(pr.sourceRefName) === branch) : undefined;
750 if (match) {
751 await openReview($, ttlMinutes, group, match.pullRequestId);
752 } else {
753 currentView = await loadBrowser($, ttlMinutes, await sessionRepoGroups($));
754 }
755 }
756 } else {
757 currentView = { mode: 'error', message: `unrecognized arguments "${args}" — use /prs, /prs <id>, or /prs --all` };
758 }
759 } catch (err: any) {
760 currentView = { mode: 'error', message: err?.message ?? String(err) };
761 }
762
763 if (currentView?.mode === 'error') {
764 return { text: summaryText(currentView) };
765 }
766
767 await $.ui
768 .open({ id: PANE_ID, title: 'PRs', focus: true, closeOnEscape: true })
769 .catch((err: any) => $.ui.log(`prs: couldn't open the panel: ${err}`));
770 return {};
771}
772
773async function handlePaneRender($: any, e: any, options: any, next: any): Promise<any> {
774 if (e.requestId !== PANE_ID || !currentView) return next(e);
775 return renderView($, e, options, currentView);
776}
777
778async function handleSessionStart($: any, e: any, next: any): Promise<any> {
779 const r = await next(e);
780 await $.command
781 .register({
782 name: 'prs',
783 description: 'Browse and review pull requests across the session\'s repos',
784 argumentHint: '[id|--all]',
785 })
786 .catch((err: any) => $.ui.log(`prs: /prs not registered: ${err}`));
787 return r;
788}
789
790async function handleDirectoryAdded($: any, e: any, next: any): Promise<any> {
791 if (typeof e.directory === 'string') await rememberDir($, e.directory).catch(() => undefined);
792 return next(e);
793}
794
795export function register(on: any, options: any) {
796 on('session.start', ($: any, e: any, next: any) => handleSessionStart($, e, next));
797 on('command.run', { command: 'prs' }, ($: any, e: any) => handleCommandRun($, e, options));
798 on('ui.render', { component: 'Pane' }, ($: any, e: any, next: any) => handlePaneRender($, e, options, next));
799 on('classic.DirectoryAdded', ($: any, e: any, next: any) => handleDirectoryAdded($, e, next));
800}
801hooks/lib/git.ts 74 lines1// Pure parsing only — nothing here touches `$`. Anything that runs `git`
2// itself lives in register.tsx (the validator requires every `$` call to
3// be textually in the hooks module file, not reached through an import).
4
5export type AdoRepoContext = {
6 org: string;
7 project: string;
8 repo: string;
9};
10
11export type GithubRepoContext = {
12 owner: string;
13 repo: string;
14};
15
16export type RepoContext =
17 | { provider: 'ado'; ado: AdoRepoContext }
18 | { provider: 'github'; github: GithubRepoContext };
19
20const DEV_AZURE_RE =
21 /^https:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/i;
22const SSH_RE = /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/i;
23const VS_RE = /^https:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/i;
24
25const GITHUB_HTTPS_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i;
26const GITHUB_SSH_RE = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i;
27
28export function parseAdoRemote(url: string): AdoRepoContext | null {
29 const trimmed = url.trim();
30
31 let m = trimmed.match(DEV_AZURE_RE);
32 if (m) return { org: decodeURIComponent(m[1]), project: decodeURIComponent(m[2]), repo: decodeURIComponent(m[3]) };
33
34 m = trimmed.match(SSH_RE);
35 if (m) return { org: m[1], project: decodeURIComponent(m[2]), repo: decodeURIComponent(m[3]) };
36
37 m = trimmed.match(VS_RE);
38 if (m) return { org: m[1], project: decodeURIComponent(m[2]), repo: decodeURIComponent(m[3]) };
39
40 return null;
41}
42
43export function parseGithubRemote(url: string): GithubRepoContext | null {
44 const trimmed = url.trim();
45
46 let m = trimmed.match(GITHUB_HTTPS_RE);
47 if (m) return { owner: m[1], repo: m[2] };
48
49 m = trimmed.match(GITHUB_SSH_RE);
50 if (m) return { owner: m[1], repo: m[2] };
51
52 return null;
53}
54
55export function parseRemote(url: string): RepoContext | null {
56 const github = parseGithubRemote(url);
57 if (github) return { provider: 'github', github };
58
59 const ado = parseAdoRemote(url);
60 if (ado) return { provider: 'ado', ado };
61
62 return null;
63}
64
65export function branchName(ref: string): string {
66 return ref.replace('refs/heads/', '');
67}
68
69export function prWebUrl(ctx: RepoContext, id: number): string {
70 return ctx.provider === 'github'
71 ? `https://github.com/${ctx.github.owner}/${ctx.github.repo}/pull/${id}`
72 : `https://dev.azure.com/${encodeURIComponent(ctx.ado.org)}/${encodeURIComponent(ctx.ado.project)}/_git/${encodeURIComponent(ctx.ado.repo)}/pullrequest/${id}`;
73}
74hooks/lib/ado.ts 53 lines1// Pure shaping only — no `$` calls here. See note in lib/git.ts.
2
3import type { AdoRepoContext } from './git';
4
5export const ADO_API_VERSION = '7.1';
6export const ADO_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
7
8export type PullRequestSummary = {
9 pullRequestId: number;
10 title: string;
11 status: string;
12 createdBy: string;
13 sourceRefName: string;
14 targetRefName: string;
15 creationDate: string;
16 isDraft: boolean;
17};
18
19export type PullRequestDetail = PullRequestSummary & {
20 description: string;
21};
22
23// ADO answers a request it won't authorize with a 203 HTML sign-in page, not a
24// 401. That page (and the x-vss-resourcetenant header, when the redirect isn't
25// followed) names the Entra tenant backing the org.
26export function isSignInPage(res: { status: number; headers: Record<string, string>; text: string }): boolean {
27 const type = res.headers['content-type'] ?? '';
28 return res.status === 203 || type.includes('text/html') || (res.text ?? '').trimStart().startsWith('<');
29}
30
31const TENANT_IN_SIGN_IN = /login\.microsoftonline\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\//i;
32
33export function tenantFromSignIn(res: { headers: Record<string, string>; text: string }): string | undefined {
34 return res.headers['x-vss-resourcetenant'] || TENANT_IN_SIGN_IN.exec(res.text ?? '')?.[1];
35}
36
37export function adoBaseUrl(ctx: AdoRepoContext): string {
38 return `https://dev.azure.com/${encodeURIComponent(ctx.org)}/${encodeURIComponent(ctx.project)}/_apis/git/repositories/${encodeURIComponent(ctx.repo)}`;
39}
40
41export function toPullRequestSummary(pr: any): PullRequestSummary {
42 return {
43 pullRequestId: pr.pullRequestId,
44 title: pr.title,
45 status: pr.status,
46 createdBy: pr.createdBy?.displayName ?? 'unknown',
47 sourceRefName: pr.sourceRefName,
48 targetRefName: pr.targetRefName,
49 creationDate: pr.creationDate,
50 isDraft: !!pr.isDraft,
51 };
52}
53hooks/lib/tree.ts 62 lines1// Pure tree-shaping only — no `$` calls here. See note in lib/git.ts.
2
3export type FileChange = { path: string; status: string; added: number | null; deleted: number | null };
4
5type DirNode = { kind: 'dir'; children: Map<string, DirNode | FileNode> };
6type FileNode = { kind: 'file'; file: FileChange };
7
8export type TreeRow =
9 | { kind: 'dir'; depth: number; name: string }
10 | { kind: 'file'; depth: number; name: string; file: FileChange };
11
12function insert(root: DirNode, file: FileChange): void {
13 const parts = file.path.split('/');
14 let node = root;
15 for (let i = 0; i < parts.length - 1; i++) {
16 const seg = parts[i];
17 const existing = node.children.get(seg);
18 if (existing && existing.kind === 'dir') {
19 node = existing;
20 } else {
21 const dir: DirNode = { kind: 'dir', children: new Map() };
22 node.children.set(seg, dir);
23 node = dir;
24 }
25 }
26 node.children.set(parts[parts.length - 1], { kind: 'file', file });
27}
28
29/** Builds a directory tree of the changed files and flattens it to rows in
30 * display order (directories first, alphabetical), compressing a chain of
31 * single-child directories into one row (`src/components/admin` on one line). */
32export function buildFileTreeRows(files: FileChange[]): TreeRow[] {
33 const root: DirNode = { kind: 'dir', children: new Map() };
34 for (const f of files) insert(root, f);
35
36 const rows: TreeRow[] = [];
37 const walk = (node: DirNode, depth: number) => {
38 const entries = [...node.children.entries()].sort(([aName, a], [bName, b]) => {
39 if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
40 return aName.localeCompare(bName);
41 });
42 for (const [name, child] of entries) {
43 if (child.kind === 'file') {
44 rows.push({ kind: 'file', depth, name, file: child.file });
45 continue;
46 }
47 let label = name;
48 let cur = child;
49 while (cur.children.size === 1) {
50 const [[onlyName, only]] = cur.children.entries();
51 if (only.kind !== 'dir') break;
52 label += '/' + onlyName;
53 cur = only;
54 }
55 rows.push({ kind: 'dir', depth, name: label });
56 walk(cur, depth + 1);
57 }
58 };
59 walk(root, 0);
60 return rows;
61}
62hooks/lib/diff.ts 11 lines1// Pure text-shaping only — no `$` calls here. See note in lib/git.ts.
2
3/** Drops git's `diff --git`/`index`/`---`/`+++` boilerplate, keeping only the
4 * `@@` hunks — the part someone reviewing actually reads. */
5export function cleanUnifiedDiff(raw: string): string {
6 const lines = raw.split('\n');
7 const start = lines.findIndex((l) => l.startsWith('@@'));
8 if (start === -1) return '(no line-level changes — rename, mode change, or binary file)';
9 return lines.slice(start).join('\n');
10}
11