Review a repo's changes in a pane: the changed files with editor-style diagnostics (pyright, ruff, tsc, eslint, terraform), a diff view, a git graph, commits…

A Claude Code mod that puts a code review pane beside your session: the files a change touches, each read whole with editor-style diagnostics, a diff view, a git graph, commits and stashes, and pull request comments.
It is for reading what you (or Claude) just changed without leaving the terminal: what differs, what is wrong with it, and whether the change brought the problem or it was already there.
/lens lists what differs from a base (uncommitted changes by default) as a tree or a list, with each file's line counts and its errors and warnings. Python is checked with pyright and ruff, TypeScript with tsc and eslint, Terraform with terraform validate; language servers are asked first where they are installed, and bring other languages with them.#12 (or a link) to review a request's changes; the repo's open requests are offered in the compare panel. Review threads show as cards on the lines they are on: reply, resolve or reopen one, post a new comment under a line, list every thread, and submit a review (approve, request changes or comment). GitHub through gh, GitLab through glab.@name to read your files against another worktree's as they stand, uncommitted work included./lens offers the recent ones./lens the repo you are in, uncommitted changes
/lens main the working tree against main
/lens ~/Code/my-repo main another repo
Press h in the pane for the keys of the screen you are on.
/config has a row for each checker, for asking language servers first, for the new-or-pre-existing pass, the pane's side padding, whether the less-used keys always show, remembering reviews, and cleaning up when a session ends.
Python, TypeScript and Terraform have command-line checkers. Any other language is checked by its language server alone, and the same server answers the lookups: types, definitions, uses, callers, the outline, colours and inlay hints, as far as that server offers them.
These are used when they are on your PATH, with nothing to set up:
| Language | Server | A project is the folder holding |
|---|---|---|
| C and C++ | clangd | compile_commands.json, compile_flags.txt, .clangd, CMakeLists.txt |
| C# | csharp-ls, else OmniSharp -lsp | *.sln, *.csproj |
| Go | gopls | go.work, go.mod |
| Rust | rust-analyzer | Cargo.toml |
| Pulumi YAML | pulumi-lsp (Pulumi.yaml, Pulumi.*.yaml only) | Pulumi.yaml |
When a change has files of one of these and its server is not installed, a note under the file list says what to install.
To add a server of your own, or change or switch off one of the above, write ~/.claude/lens/servers.json. It is read again at each scan:
{
"servers": {
"zls": {
"language": "Zig",
"extensions": [".zig"],
"command": ["zls"],
"rootMarkers": ["build.zig"],
"install": "brew install zls"
},
"gopls": { "command": ["gopls", "-remote=auto"] },
"clangd": { "disabled": true }
}
}
A name the table already has (pyright, tsserver, terraform-ls, clangd, csharp-ls, gopls, rust-analyzer, pulumi-lsp) changes that server, one field at a time; any other name adds a server, which is tried before the built-in ones. The name is also what its problems are labelled with.
| Field | What it is |
|---|---|
extensions | The file extensions the server reads, each with its dot. Upper or lower case is the same. |
filenames | Whole file names it reads (* and ? stand for anything). A name counts before any extension. |
command | The program and its arguments, speaking the Language Server Protocol on stdin and stdout. A list of such lists is tried in order: the first whose program is installed is used. |
languageId | What the protocol calls the language: "go", or one for each extension ({ ".c": "c", ".cc": "cpp" }). Left out, it is the extension without its dot. |
rootMarkers | Files that mark a project, tried in order: the nearest folder at or above the file that holds the first one found is where the server is started (* allowed). With none found it is the repo; with none listed, each folder is its own project. |
language | What to call the language in a note. Left out, it is the server's name. |
install | How to get the server, shown when it is not installed. |
disabled | true switches the server off. |
A new server needs a command and either extensions or filenames. pyright and tsserver are found and started in ways of their own, so only disabled can be set for them. An entry with something wrong in it is left out, and a note under the file list says which and why; it never stops the others from working.
This file is the only place servers are read from. Nothing in the repo you are reviewing can add one, because an entry names a command that is run on your machine: a repo you only meant to read must not get to choose it. The servers themselves do read the project's files, as they do in an editor.
Nothing is required beyond git; each checker is used where it is found.
uvx)node_modules (tsc, eslint) and Nodeterraform, and optionally terraform-lsgh or glab, signed inThis mod uses Claude Code's function-hooks plugin API.
Clone it into your personal skills folder, where Claude Code loads it in every session:
git clone https://github.com/BuddyLim/claude-code-mod-lens ~/.claude/skills/lens
While a session runs: exports of the commits it checks under $TMPDIR/lens-base, a language-server keeper under /tmp/lens-lsp-<uid>, and, for a pull request, refs under refs/lens/ in that repo. All three are removed when the session ends (a setting). Nothing is ever checked out, committed or pushed unless you press the key for it.
claude plugin validate ~/.claude/skills/lens
claude plugin test ~/.claude/skills/lens
uv run --no-project python ~/.claude/skills/lens/tests/bridge_test.py
The last runs what claude plugin test cannot, since it needs real processes: the bridge's table of servers and its config file, and a small fake language server driven through the bridge's own daemon (one of its own, in a temp folder).
MIT
hooks/register.tsx 2485 lines1// The hooks module: the wiring. It declares the session's state, holds what
2// is too big or too short-lived to be state, and has every function that
3// touches the engine handle `$` (the engine follows `$` only into functions
4// declared at the top of this file, so they cannot live anywhere else). Each
5// of those is thin: it makes a `run` from the handle, calls the module that
6// knows the subject (git.ts, check.ts, scan.ts, lsp.ts, review.ts), and puts
7// the answer where the screens read it. The render hook builds each screen's
8// model and actions and routes to the screen (screens/), which is a pure view.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register, RenderChildren } from 'claude-code'
12
13import type { LineRange, Listing, Lookup, Recent, Scan, Span, View, ChangedFile, LineStat } from '../types'
14import { hasNameSearch } from './check'
15import { diagsByLine, diagsOf } from './diags'
16import * as git from './git'
17import type { Blamed } from './git'
18import {
19 LIST_ROWS,
20 callsList,
21 implementationsList,
22 lookupOf,
23 namesList,
24 usesList,
25 threadsList,
26} from './lists'
27import type { Run as ServerRun } from './lsp'
28import {
29 isServed,
30 lspCalls,
31 lspInlayHints,
32 lspOutline,
33 lspReferences,
34 lspSemanticTokens,
35 lspSymbol,
36 lspWorkspaceSymbols,
37} from './lsp'
38import type { InlayHint, SemanticToken } from './lsp-types'
39import { ISSUES_SENT, codeBlock, diagBlock, issueList, quoteBlock, talkBlock } from './prompt'
40import { cleanUp, recentOf, remember, settledRecents } from './recents'
41import { findingComments, isFinding, placeOf } from './ledger'
42import type { Comment, Run as ForgeRun } from './review'
43import {
44 fetchComments,
45 parseRequest,
46 postComment,
47 listRequests,
48 replyComment,
49 requestOfBranch,
50 repoPrefix,
51 resolveRequest,
52 resolveThread,
53 submitReview,
54} from './review'
55import type { Run } from './run'
56import { tail } from './run'
57import type { Job } from './scan'
58import { allFilesOf, historyOf, isQueued, noteTouched, scanRepo } from './scan'
59import type { FileWindow, Insight } from './screens/file'
60import { fileScreen } from './screens/file'
61import type { Shell } from './screens/frame'
62import { frame, kitOf } from './screens/frame'
63import type { GraphWindow } from './screens/graph'
64import { graphScreen } from './screens/graph'
65import { helpScreen } from './screens/help'
66import { listScreen } from './screens/list'
67import { isMarkdownFile, markdownScreen } from './screens/markdown'
68import { recentsScreen } from './screens/recents'
69import { treeScreen } from './screens/tree'
70import { foldOf } from './semantic'
71import type { Settings } from './settings'
72import { DEFAULTS, settingsOf } from './settings'
73import { readSource } from './source'
74import {
75 NO_CRUMB,
76 NO_LISTING,
77 NO_PICKED,
78 NO_SCAN,
79 NO_SOURCE,
80 NO_SYMBOL,
81 NO_VIEW,
82 changedDiags,
83 comparisonOf,
84 settledPicked,
85 settledScan,
86 settledView,
87 totalsOf,
88} from './state'
89import { clamp, foldEnd } from './text'
90
91const PANE = 'lens'
92// How many unchanged files opened from the tree of every file stay among
93// those checked.
94const EXTRA_FILES = 20
95// The most lines of code sent to the prompt with a review thread.
96const TALK_CODE = 80
97
98// The session's state. Its shapes are the contract's (types/index.d.ts) and
99// its defaults are in state.ts; the atoms are written here because the
100// engine reads a state reference only in the file that uses it.
101const view = atom({ plugin: 'lens', key: 'view' } as const, NO_VIEW)
102const scan = atom({ plugin: 'lens', key: 'scan' } as const, NO_SCAN)
103const source = atom({ plugin: 'lens', key: 'source' } as const, NO_SOURCE)
104const listing = atom({ plugin: 'lens', key: 'listing' } as const, NO_LISTING)
105const picked = atom({ plugin: 'lens', key: 'picked' } as const, NO_PICKED)
106// The repos reviewed lately, as the store holds them between sessions: the
107// copy the recents screen draws from, written by `rememberReview`,
108// `forgetReview` and the /lens command.
109const recents = atom({ plugin: 'lens', key: 'recents' } as const, [] as Recent[])
110// The frame of the busy mark, stepped by the timer while a scan runs.
111const spin = atom({ plugin: 'lens', key: 'spin' } as const, 0)
112const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
113
114// What the module holds beside the state. A reload of the mod drops all of
115// it, and each is asked for again where it is missed. Every one below is
116// written only by the function or hook its comment names; the rest of the
117// session's caches belong to the modules that fill them (the history and
118// the list of every file to scan.ts, the base's diagnostics to check.ts).
119
120// The person's settings, read from the module's options when it registers
121// (a change in the config menu loads the module again with the new ones).
122let settings: Settings = DEFAULTS
123
124// How the repo stood when it was last looked at (see `git.repoMark`), and
125// the timer's count of ticks: every so many, `watchRepo` looks again.
126let repoMarked: { repo: string; mark: string } | undefined
127let ticks = 0
128let isWatching = false
129// Ticks of the 400 ms timer between two looks at the repo: about 4 seconds.
130const WATCH_TICKS = 10
131
132// The scan queue, written by anything that wants a scan and taken by the
133// timer: a reload drops a waiting scan, and the next refresh asks again.
134let job: Job | undefined
135let isBusy = false
136
137// The open file's coloured lines, written by `loadSource`. A whole file is
138// too much to keep as state, so a reload loses them and the render hook asks
139// for them again through `wanted`, which the timer takes.
140let cache:
141 | {
142 path: string
143 // The commit the file was read at; '' for the working tree's.
144 commit: string
145 lines: Span[][]
146 removed: Record<number, string[]>
147 // A commit's own changed lines; the working tree's come from the scan.
148 changed: LineRange[] | undefined
149 }
150 | undefined
151let wanted: string | undefined
152let isLoading = false
153
154// The commit or stash message as typed so far, and the comment being typed:
155// each field holds its own text, and these are the copies the buttons beside
156// it read. Written by the fields' actions, and cleared by `commitFiles`,
157// `stashFiles` and `postReview` once what was typed has been used.
158let draft = ''
159let draftBody = ''
160let commentDraft = ''
161// How many comments have been sent or dropped: see `FileModel.commentRound`.
162let commentRound = 0
163// The same for the review being written in the file tree's box.
164let reviewDraft = ''
165let reviewRound = 0
166
167// How the language-server bridge runs its commands, made by `serverRun` from
168// the first handle that needs it.
169let servers: ServerRun | undefined
170
171// What the language server knows of the file the file screen shows, written
172// by `loadInsight` (and dropped by `loadSource` for a commit's file). Read
173// after the file itself, so the file draws first and gains these.
174let insight: ({ path: string } & Insight) | undefined
175// The file `loadInsight` is reading for, while it is: the screen says so.
176let insightFor: string | undefined
177
178// Opens the pane on a file at a line, for another mod that names the place: a
179// path that is absolute, or from the session's folder. The repository the
180// file is in becomes the one under review, unless the folder under review
181// already holds the file.
182const showPlace = async ($: EngineInterface, named: string, line: number): Promise<void> => {
183 const run = runOf($)
184 const root = await $.session.cwd().catch(() => '')
185 const full = placeOf(named, root)
186 const now = await read($, view)
187 let repo = now.repo !== '' && full.startsWith(`${now.repo}/`) ? now.repo : ''
188
189 if (repo === '') {
190 const top = await run(['git', '-C', full.replace(/\/[^/]*$/, ''), 'rev-parse', '--show-toplevel'])
191 repo = top.exitCode === 0 ? top.stdout.trim() : ''
192
193 if (repo === '' || !full.startsWith(`${repo}/`)) {
194 $.ui.toast(`Lens: ${named} is not in a git repository`)
195
196 return
197 }
198
199 await openReview($, repo, undefined)
200 }
201
202 const path = full.slice(repo.length + 1)
203
204 await loadSource($, repo, path)
205 await update(
206 $,
207 view,
208 (last): View => ({
209 ...last,
210 screen: 'file',
211 file: path,
212 commit: '',
213 origin: 'tree',
214 top: Math.max(1, line - 3),
215 cursor: -1,
216 isDiff: false,
217 isPreview: false,
218 symbol: NO_SYMBOL,
219 crumb: NO_CRUMB,
220 }),
221 )
222 await $.ui.open({ id: PANE, title: 'Lens', focus: true })
223}
224
225// The ledger mod's run, where that mod is loaded: its findings are drawn with
226// a request's comments.
227const LEDGER_RUN = { plugin: 'ledger', key: 'run' } as const
228
229// The comments on the pull or merge request under review, as the forge gave
230// them, with the folder under review's place in the repo (`prefix`). Written
231// by `loadComments`; `postReview` adds the comment it posted.
232let commentsCache: { key: string; prefix: string; comments: Comment[] } | undefined
233
234// The open request of the branch checked out, where it has one: its comments
235// show on the working tree's files without a comparison being set up.
236// Written by `findBranchRequest`, with each scan of the working tree.
237let branchRequest:
238 | {
239 repo: string
240 branch: string
241 typed: string
242 label: string
243 // What it is called, the branch it targets, and when the forge said so.
244 title: string
245 baseRef: string
246 url: string
247 at: number
248 // What the branch changes since it forked from that target, read
249 // again with every scan: the commit it forked at, and the files.
250 base: string
251 files: ChangedFile[]
252 stats: Record<string, LineStat>
253 }
254 | undefined
255
256// The repo's open pull or merge requests, for the compare panel to offer.
257// Written by `loadRequests`, when the panel opens.
258let requestsCache: { repo: string; list: { typed: string; title: string }[] } | undefined
259
260// What the folder the breadcrumb last opened holds, written by `loadCrumb`.
261let crumbCache: { dir: string; entries: string[] } | undefined
262
263// Who last changed each line of the file the blame column was asked for,
264// written by `loadBlame`.
265let blameCache: { path: string; commit: string; lines: Blamed[] } | undefined
266
267// What the render hook leaves for the scroll hook and the timer: the graph's
268// and the file's windows as last drawn (see `GraphWindow`, `FileWindow`),
269// and whether the pane's own scroll needs holding one row down, for a screen
270// that draws its own window; the drawing asks, the timer does it.
271let graphWindow: GraphWindow = { header: 0, kinds: [], maxTop: 0, bodyMax: 0 }
272let fileWindow: FileWindow = { maxTop: 1, crumbBox: undefined }
273let wantPin = false
274
275const noteEdit = async ($: EngineInterface, path: string): Promise<void> => {
276 const { repo, isTelling } = await read($, view)
277
278 if (repo !== '' && path.startsWith(`${repo}/`)) {
279 job = { isProject: false }
280
281 if (isTelling ?? false) {
282 noteTouched(path.slice(repo.length + 1))
283 }
284 }
285}
286
287// Every command of the session is run through this: the handle's own
288// `process.run`, made never to reject (see `Run`).
289const runOf =
290 ($: EngineInterface): Run =>
291 (argv, init) =>
292 $.process
293 .run(argv, init ?? {})
294 .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
295
296// Reads a whole file as coloured lines and keeps it for the file screen, with
297// what the diff view interleaves.
298//
299// With a `commit` the file is read as that commit left it, and its diff is
300// what the commit changed; without one it is the working tree's file, against
301// the base.
302const loadSource = async (
303 $: EngineInterface,
304 repo: string,
305 path: string,
306 commit = '',
307): Promise<void> => {
308 const run = runOf($)
309 const committed = commit === '' ? undefined : await git.fileAt(run, repo, path, commit)
310 const { lines, note } = await readSource(
311 run,
312 file => $.fs.read(file).catch(() => ''),
313 repo,
314 path,
315 committed,
316 )
317 const { base, target, diffBase } = await read($, view)
318 // A file of the branch's request is read against where the request forked.
319 const own = commit === '' && diffBase?.path === path && diffBase.base !== '' ? diffBase.base : ''
320 const diff = await git.fileDiff(run, repo, path, commit, own || base, target ?? '', own !== '')
321
322 cache = { path, commit, lines, removed: diff.removed, changed: diff.changed }
323 const stamp = await $.clock.now()
324 await update($, source, () => ({ path, lineCount: lines.length, note, stamp }))
325
326 // What the language server adds comes after, so the file is not kept
327 // waiting for it; only the working tree's files are on disk for a server.
328 if (commit === '') {
329 void loadInsight($, repo, path)
330 } else {
331 insight = undefined
332 }
333}
334
335// The repos reviewed lately, as the store holds them.
336const readRecents = async ($: EngineInterface): Promise<Recent[]> =>
337 settledRecents(await $.store.get('recents').catch(() => undefined))
338
339// Keeps the review as it stands (its repo, comparison and layout) for the
340// sessions to come, where the person has not switched remembering off.
341const rememberReview = async ($: EngineInterface): Promise<void> => {
342 const now = await read($, view)
343
344 if (!settings.remembers || now.repo === '') {
345 return
346 }
347
348 const list = remember(
349 await readRecents($),
350 recentOf(now, await $.clock.now(), await git.mainRepoOf(runOf($), now.repo)),
351 )
352
353 await $.store.set('recents', list).catch(() => undefined)
354 await update($, recents, () => list)
355}
356
357const forgetReview = async ($: EngineInterface, repo: string): Promise<void> => {
358 const list = (await readRecents($)).filter(one => one.repo !== repo)
359
360 await $.store.set('recents', list).catch(() => undefined)
361 await update($, recents, () => list)
362}
363
364// Starts a review of a repo and opens the pane on it. With no `base` named,
365// the repo is picked up where it was last left: its layout, and its
366// comparison where git still knows both sides (a pull or merge request is
367// fetched again). Answers what it did, in a sentence.
368const openReview = async (
369 $: EngineInterface,
370 repo: string,
371 base: string | undefined,
372): Promise<string> => {
373 const kept =
374 base === undefined && settings.remembers
375 ? (await readRecents($)).find(one => one.repo === repo)
376 : undefined
377
378 await update($, view, () => ({
379 ...NO_VIEW,
380 repo,
381 base: base ?? 'HEAD',
382 layout: kept?.layout ?? 'tree',
383 isBrowsing: kept?.isBrowsing ?? false,
384 }))
385 await update($, scan, () => ({ ...NO_SCAN, status: 'running' }))
386 job = { isProject: false }
387 await $.ui.open({ id: PANE, title: 'Lens', focus: true })
388
389 if (kept === undefined || (kept.base === 'HEAD' && kept.target === '')) {
390 return `Reviewing ${repo} against ${base ?? 'HEAD'}.`
391 }
392
393 if (kept.requestTyped !== '') {
394 void startCompare($, repo, '', kept.requestTyped)
395
396 return `Reviewing ${repo}: fetching ${kept.requestTyped} again, as you left it.`
397 }
398
399 const run = runOf($)
400 const isKnown =
401 (await git.isCommit(run, repo, kept.base)) &&
402 (kept.target === '' || (await git.isCommit(run, repo, kept.target)))
403
404 if (!isKnown) {
405 return `Reviewing ${repo} against HEAD (what it was last compared with is gone).`
406 }
407
408 await update($, view, (last): View => ({ ...last, base: kept.base, target: kept.target }))
409 job = { isProject: false }
410
411 return `Reviewing ${repo}: ${kept.target === '' ? 'the working tree' : kept.target} against ${kept.base}, as you left it.`
412}
413
414// Scans again when the repo has changed under the pane: a commit, a
415// checkout, a merge, an edit or a stash made anywhere (Claude's own shell,
416// another terminal, an editor). Only while the pane is open, and never
417// while a scan is running or waiting.
418const watchRepo = async ($: EngineInterface): Promise<void> => {
419 const { repo } = await read($, view)
420
421 if (repo === '' || isBusy || job !== undefined) {
422 return
423 }
424
425 const open = await $.ui.panes().catch(() => [])
426
427 if (!open.some(one => one.id === PANE)) {
428 return
429 }
430
431 const mark = await git.repoMark(runOf($), repo)
432
433 if (mark === '') {
434 return
435 }
436
437 // The first look only notes how things stand: the scan that opened the
438 // review has just read them.
439 if (repoMarked !== undefined && repoMarked.repo === repo && repoMarked.mark !== mark) {
440 job ??= { isProject: false }
441 }
442
443 repoMarked = { repo, mark }
444}
445
446// Runs one scan with this handle. The pipeline itself is in scan.ts; what
447// it reads and writes of the session goes through the ports made here.
448const runScan = async ($: EngineInterface, taken: Job): Promise<void> => {
449 const now = await read($, view)
450
451 // Every change of what is compared starts a scan, so this is where the
452 // review is kept for next time.
453 void rememberReview($)
454
455 // With no request under review, the branch checked out may have one open:
456 // its comments are then read as a request's are.
457 const requestTyped =
458 (now.target ?? '') !== '' && (now.requestTyped ?? '') !== ''
459 ? now.requestTyped
460 : (now.target ?? '') === ''
461 ? await findBranchRequest($, now.repo)
462 : ''
463
464 // A comparison with another worktree is with its files as they stand now:
465 // they are read again, and the scan runs against that.
466 let base = now.base
467
468 if ((now.baseWorktree ?? '') !== '') {
469 const taken = await git.snapshotWorktree(runOf($), now.baseWorktree)
470
471 if (taken.hash !== '' && taken.hash !== base) {
472 base = taken.hash
473 await update($, view, (last): View => ({ ...last, base }))
474 }
475 }
476
477 await scanRepo(
478 {
479 run: runOf($),
480 servers: serverRun($),
481 readFile: path => $.fs.read(path).catch(() => ''),
482 readScan: () => read($, scan),
483 writeScan: change => update($, scan, change),
484 onListed: async () => {
485 if (now.screen === 'file') {
486 await loadSource($, now.repo, now.file, now.commit ?? '')
487 }
488 },
489 readComments: typed => loadComments($, now.repo, typed),
490 showStatus: text => {
491 $.ui.status(text === '' ? undefined : text)
492 },
493 tellClaude: text =>
494 $.session
495 .append({ message: { type: 'user', content: [{ type: 'text', text }] } })
496 .catch(() => undefined),
497 // The file being read, where it is the side the checkers run over.
498 openFile: async () => {
499 const { screen, file, commit, target } = await read($, view)
500
501 return screen === 'file' && (commit ?? '') === (target ?? '') ? file : ''
502 },
503 isOvertaken: () => job !== undefined,
504 },
505 {
506 repo: now.repo,
507 base,
508 target: now.target ?? '',
509 extra: now.extra ?? [],
510 isBrowsing: now.isBrowsing ?? false,
511 isTelling: now.isTelling ?? false,
512 requestTyped,
513 use: settings.checkers,
514 marksNew: settings.marksNew,
515 },
516 taken,
517 )
518}
519
520// Throws away the uncommitted changes in the given files, which cannot be
521// undone, and scans again whatever happened.
522const discardFiles = async (
523 $: EngineInterface,
524 repo: string,
525 tracked: string[],
526 staged: string[],
527 untracked: string[],
528): Promise<void> => {
529 const refusal = await git.discard(runOf($), repo, tracked, staged, untracked)
530 const count = tracked.length + staged.length + untracked.length
531
532 $.ui.toast(
533 refusal === '' ? `Discarded the changes in ${count} ${count === 1 ? 'file' : 'files'}` : refusal,
534 { timeoutMs: refusal === '' ? 4000 : 10_000 },
535 )
536 await update($, view, last => ({ ...last, checked: [], isDiscarding: false }))
537 job = { isProject: false }
538}
539
540// How the language-server bridge runs its commands: kept for the session, so
541// the bridge's script is written once.
542const serverRun = ($: EngineInterface): ServerRun => (servers ??= runOf($))
543
544// Reads what the language server knows of a whole file, for the file screen:
545// its outline (exact folds, the breadcrumb), what each name is (colours), the
546// file's own lines (a server counts a tab as one column, the screen draws
547// four), and, when they are switched on, its inlay hints. A file outside the
548// folder under review, or of a kind no server reads, has none.
549const loadInsight = async ($: EngineInterface, repo: string, path: string): Promise<void> => {
550 if (path.startsWith('/') || !isServed(path)) {
551 insight = undefined
552
553 return
554 }
555
556 const run = serverRun($)
557
558 insightFor = path
559
560 const { isHinting } = await read($, view)
561 const [raw, outline, semantic, hinted] = await Promise.all([
562 $.fs.read(`${repo}/${path}`).catch(() => ''),
563 lspOutline(run, repo, path),
564 lspSemanticTokens(run, repo, path),
565 (isHinting ?? false) ? lspInlayHints(run, repo, path, 1, 1_000_000) : undefined,
566 ])
567 const tokens = new Map<number, SemanticToken[]>()
568 const hints = new Map<number, InlayHint[]>()
569
570 for (const token of semantic.tokens) {
571 tokens.set(token.line, [...(tokens.get(token.line) ?? []), token])
572 }
573
574 for (const hint of hinted?.hints ?? []) {
575 hints.set(hint.line, [...(hints.get(hint.line) ?? []), hint])
576 }
577
578 insight = { path, raw: raw.split('\n'), items: outline.items, tokens, hints }
579
580 if (insightFor === path) {
581 insightFor = undefined
582 }
583
584 // The file screen reads the source's stamp: a new one redraws it.
585 await update($, source, last => ({ ...last, stamp: last.stamp + 1 }))
586
587 if ((isHinting ?? false) && (hinted?.hints.length ?? 0) === 0 && (hinted?.notes.length ?? 0) > 0) {
588 $.ui.toast(hinted?.notes[0] ?? '', { timeoutMs: 6000 })
589 }
590}
591
592// Shows a list of places (or anything with a place) on the list screen; one
593// with nothing in it is said in a toast instead, with the server's reason
594// (`notes`) where it gave one.
595const showList = async (
596 $: EngineInterface,
597 { title, rows, prompt, commit }: Listing,
598 notes: readonly string[],
599): Promise<void> => {
600 if (rows.length === 0) {
601 $.ui.toast(notes[0] ?? `${title}: nothing found`, { timeoutMs: 6000 })
602
603 return
604 }
605
606 await update($, listing, () => ({
607 title,
608 rows: rows.slice(0, LIST_ROWS),
609 prompt,
610 ...(commit === undefined ? {} : { commit }),
611 }))
612 // Back from the list returns to the screen it was asked for on.
613 await update(
614 $,
615 view,
616 (last): View => ({
617 ...last,
618 screen: 'list',
619 listBack: last.screen === 'list' ? (last.listBack ?? 'file') : last.screen === 'tree' ? 'tree' : 'file',
620 }),
621 )
622}
623
624// Everywhere the looked-up name is used.
625const listReferences = async ($: EngineInterface, repo: string, at: Lookup): Promise<void> => {
626 const answer = await lspReferences(serverRun($), repo, at.file, at.at, at.col)
627
628 await showList($, usesList(at.name, at.file, answer.places), answer.notes)
629}
630
631// Who calls the looked-up function, or what it calls.
632const listCalls = async (
633 $: EngineInterface,
634 repo: string,
635 at: Lookup,
636 direction: 'incoming' | 'outgoing',
637): Promise<void> => {
638 const answer = await lspCalls(serverRun($), repo, at.file, at.at, at.col, direction)
639
640 await showList($, callsList(at.name, direction, answer.calls), answer.notes)
641}
642
643// What implements the looked-up interface, abstract method or protocol.
644const listImplementations = async ($: EngineInterface, repo: string, at: Lookup): Promise<void> => {
645 const answer = await lspSymbol(serverRun($), repo, at.file, at.at, at.col)
646
647 await showList(
648 $,
649 implementationsList(at.name, answer.implementations ?? []),
650 answer.notes.length > 0 ? answer.notes : ['The language server knows of none'],
651 )
652}
653
654// Names anywhere in the project that match what was typed. `near` is a file
655// of the project to search: it decides the language and the project.
656const searchSymbols = async (
657 $: EngineInterface,
658 repo: string,
659 query: string,
660 near: string,
661): Promise<void> => {
662 if (query.trim() === '') {
663 return
664 }
665
666 if (near === '') {
667 $.ui.toast('There is no Python or TypeScript file here to search from')
668
669 return
670 }
671
672 const answer = await lspWorkspaceSymbols(serverRun($), repo, query.trim(), near, {
673 limit: LIST_ROWS,
674 })
675
676 await showList($, namesList(query.trim(), answer.hits), answer.notes)
677}
678
679// Asks the language server what a name on a line of a file is, and keeps the
680// answer for the file screen to show. The column is found on the file's own
681// text: the screen draws a tab as spaces, which a server does not count.
682const lookUp = async (
683 $: EngineInterface,
684 repo: string,
685 file: string,
686 line: number,
687 name: string,
688): Promise<void> => {
689 const raw = await $.fs.read(`${repo}/${file}`).catch(() => '')
690 const col = Math.max(0, (raw.split('\n')[line - 1] ?? '').indexOf(name)) + 1
691 const answer = await lspSymbol(serverRun($), repo, file, line, col)
692
693 if (answer.text === '' && answer.notes.length > 0) {
694 $.ui.toast(answer.notes[0] ?? 'The language server did not answer', { timeoutMs: 8000 })
695
696 return
697 }
698
699 await update($, view, last => ({ ...last, symbol: lookupOf(file, name, line, col, answer) }))
700}
701
702// Reads what a folder of the folder under review holds, for the breadcrumb's
703// list: folders first (their names end with a slash), then files, git's own
704// folder left out. `left` is the column the list opens under.
705const loadCrumb = async (
706 $: EngineInterface,
707 repo: string,
708 dir: string,
709 left: number,
710): Promise<void> => {
711 const ran = await runOf($)(['ls', '-1Ap', '--', dir === '' ? '.' : dir], { cwd: repo })
712
713 if (ran.exitCode !== 0) {
714 $.ui.toast(`That folder could not be read: ${tail(ran.stderr)}`, { timeoutMs: 6000 })
715
716 return
717 }
718
719 const names = ran.stdout.split('\n').filter(name => name !== '' && name !== '.git/')
720
721 crumbCache = {
722 dir,
723 entries: [
724 ...names.filter(name => name.endsWith('/')),
725 ...names.filter(name => !name.endsWith('/')),
726 ],
727 }
728 await update(
729 $,
730 view,
731 (last): View => ({ ...last, crumb: { kind: 'dir', dir, level: 0, left, top: -1 } }),
732 )
733}
734
735// Reads who last changed each line of a file (as a commit left it, or as the
736// working tree has it) and keeps it for the file screen's blame column.
737const loadBlame = async (
738 $: EngineInterface,
739 repo: string,
740 path: string,
741 commit: string,
742): Promise<void> => {
743 const answer = await git.blame(runOf($), repo, path, commit, () => $.clock.now())
744
745 if ('refusal' in answer) {
746 $.ui.toast(`git blame did not go through: ${answer.refusal}`, { timeoutMs: 8000 })
747
748 return
749 }
750
751 blameCache = { path, commit, lines: answer.lines }
752 await update($, view, last => ({ ...last, isBlame: true }))
753}
754
755// Asks before undoing the last commit, saying whether it has been pushed:
756// one that has stays on the remote, and the branch here falls behind it.
757const askUndo = async ($: EngineInterface, repo: string): Promise<void> => {
758 const pushed = await git.remotesWithHead(runOf($), repo)
759
760 await update($, view, last => ({
761 ...last,
762 isUndoing: true,
763 undoNote:
764 pushed.length === 0
765 ? 'It has not been pushed, so nothing else has it.'
766 : `It is already on ${pushed[0] ?? 'a remote'}: undoing it here leaves it there, and this branch behind it.`,
767 }))
768}
769
770// Takes the last commit back and keeps what it changed, as uncommitted edits.
771const undoCommit = async ($: EngineInterface, repo: string): Promise<void> => {
772 await report(
773 $,
774 await git.undoLastCommit(runOf($), repo),
775 'Undid the last commit; its changes are uncommitted again',
776 )
777 await update($, view, last => ({ ...last, isUndoing: false, selected: '' }))
778}
779
780// Adds text to the end of the person's draft, on its own line, and never
781// submits it: sending stays theirs.
782const sendToComposer = async ($: EngineInterface, text: string): Promise<void> => {
783 const draft = await $.prompt.read().then(
784 box => box.text,
785 () => '',
786 )
787 const lead = draft === '' || draft.endsWith('\n') ? '' : '\n'
788 const filled = await $.prompt.fill({ text: `${lead}${text}\n`, mode: 'append' })
789
790 $.ui.toast(
791 filled.isFilled
792 ? 'Added to the prompt'
793 : `Could not add to the prompt${filled.refusal === undefined ? '' : ` (${filled.refusal})`}`,
794 )
795}
796
797// Says how a git command that changes the repo went: `done` on success, the
798// command's `refusal` (git's own last words) where it has one. A success
799// clears the ticks and scans again, since the working tree is no longer what
800// the pane shows.
801const report = async ($: EngineInterface, refusal: string, done: string): Promise<boolean> => {
802 if (refusal !== '') {
803 $.ui.toast(refusal, { timeoutMs: 10_000 })
804
805 return false
806 }
807
808 $.ui.toast(done)
809 await update($, view, last => ({ ...last, checked: [] }))
810 job = { isProject: false }
811
812 return true
813}
814
815// Commits the ticked files and nothing else.
816const commitFiles = async (
817 $: EngineInterface,
818 repo: string,
819 paths: readonly string[],
820 message: string,
821 body = '',
822): Promise<void> => {
823 if (message.trim() === '') {
824 $.ui.toast('Type a commit message first')
825
826 return
827 }
828
829 if (
830 await report(
831 $,
832 await git.commit(runOf($), repo, paths, message, body),
833 `Committed ${paths.length} ${paths.length === 1 ? 'file' : 'files'}`,
834 )
835 ) {
836 draft = ''
837 draftBody = ''
838 }
839}
840
841// Stashes the ticked files, the untracked ones among them included.
842const stashFiles = async (
843 $: EngineInterface,
844 repo: string,
845 paths: readonly string[],
846 message: string,
847): Promise<void> => {
848 const label = message.trim() || `${paths.length} ${paths.length === 1 ? 'file' : 'files'}`
849
850 if (
851 await report(
852 $,
853 await git.stash(runOf($), repo, paths, label),
854 `Stashed ${paths.length} ${paths.length === 1 ? 'file' : 'files'}`,
855 )
856 ) {
857 draft = ''
858 }
859}
860
861const applyStash = async (
862 $: EngineInterface,
863 repo: string,
864 ref: string,
865 isPop: boolean,
866): Promise<void> => {
867 await report(
868 $,
869 await git.restoreStash(runOf($), repo, ref, isPop),
870 isPop ? `Popped ${ref}` : `Applied ${ref}`,
871 )
872}
873
874// How the forge module runs its commands: in the folder under review.
875const forgeRun =
876 (run: Run, repo: string): ForgeRun =>
877 (argv, timeoutMs = 60_000) =>
878 run(argv, { cwd: repo, timeoutMs })
879
880// How long what the forge said of a branch's request is taken as still so.
881const BRANCH_REQUEST_MS = 5 * 60_000
882
883// Finds the open request of the branch checked out, asking the forge at most
884// once in a while for the same branch; '' when it has none.
885const findBranchRequest = async ($: EngineInterface, repo: string): Promise<string> => {
886 const run = runOf($)
887 const branch = (await run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo })).stdout.trim()
888 const at = await $.clock.now()
889
890 if (
891 branchRequest === undefined ||
892 branchRequest.repo !== repo ||
893 branchRequest.branch !== branch ||
894 at - branchRequest.at > BRANCH_REQUEST_MS
895 ) {
896 const found = await requestOfBranch(forgeRun(run, repo), branch)
897
898 branchRequest = {
899 repo,
900 branch,
901 typed: found?.typed ?? '',
902 label: found?.label ?? '',
903 title: found?.title ?? '',
904 baseRef: found?.baseRef ?? '',
905 url: found?.url ?? '',
906 at,
907 base: '',
908 files: [],
909 stats: {},
910 }
911 }
912
913 // The forge's word is kept a while; what the branch changes is git's to
914 // say, and a commit made since changes it.
915 if (branchRequest.typed !== '' && branchRequest.baseRef !== '') {
916 Object.assign(branchRequest, await git.requestChanges(run, repo, branchRequest.baseRef))
917 }
918
919 return branchRequest.typed
920}
921
922// Asks the forge for the repo's open requests and has the compare panel,
923// which is already open, drawn again with them.
924const loadRequests = async ($: EngineInterface, repo: string): Promise<void> => {
925 requestsCache = { repo, list: await listRequests(forgeRun(runOf($), repo)) }
926 await update($, view, (last): View => ({ ...last }))
927}
928
929// Starts a comparison between two things the person named: `side` is what is
930// read (a branch or commit, or '' for the working tree) and `against` is what
931// it is compared with. Each name is checked with git first, so a typo is said
932// and nothing changes. Nothing is checked out: git diffs the two as they are.
933const startCompare = async (
934 $: EngineInterface,
935 repo: string,
936 side: string,
937 against: string,
938): Promise<void> => {
939 const run = runOf($)
940 const known = async (name: string): Promise<boolean> =>
941 name === '' || (await git.isCommit(run, repo, name))
942 let [from, to] = [side.trim(), against.trim()]
943 let request = ''
944 let typed = ''
945
946 // A request is a comparison by itself (its head with where it forked from
947 // its target), so it may be typed in either field, and what the other
948 // field holds is set aside.
949 const isRequest = async (name: string): Promise<boolean> =>
950 name !== '' && parseRequest(name) !== undefined && !(await known(name))
951
952 if (await isRequest(from)) {
953 ;[from, to] = ['', from]
954 } else if (await isRequest(to)) {
955 from = ''
956 } else if (from.startsWith('@')) {
957 // A worktree, likewise, whichever field it was typed in.
958 ;[from, to] = ['', from]
959 }
960
961 if (to === '') {
962 $.ui.toast('Name a branch, a commit or a pull request to compare with')
963
964 return
965 }
966
967 // "@name" is another worktree of the repo as its files stand, uncommitted
968 // work and all: a commit is made of them (nothing of that worktree
969 // changes) and compared with like any other. `beside` is its folder.
970 let beside = ''
971
972 if (to.startsWith('@')) {
973 const name = to.slice(1)
974 const other = ((await read($, scan)).worktrees ?? []).find(
975 one => !one.isCurrent && one.path.split('/').pop() === name,
976 )
977
978 if (other === undefined) {
979 $.ui.toast(`This repo has no other worktree called "${name}"`, { timeoutMs: 8000 })
980
981 return
982 }
983
984 const taken = await git.snapshotWorktree(run, other.path)
985
986 if (taken.hash === '') {
987 $.ui.toast(`${name} could not be read as it stands: ${taken.refusal}`, { timeoutMs: 10_000 })
988
989 return
990 }
991
992 beside = other.path
993 to = taken.hash
994 }
995
996 // What is not a ref but reads as a pull or merge request (#12, !34, a
997 // link) is looked up on the forge: its head is fetched under a ref of the
998 // mod's own, and it is compared with where it forked from its target.
999 if (from === '' && !(await known(to)) && parseRequest(to) !== undefined) {
1000 $.ui.toast(`Fetching ${to}…`)
1001
1002 const answer = await resolveRequest(forgeRun(run, repo), to)
1003
1004 if ('error' in answer) {
1005 $.ui.toast(answer.error, { timeoutMs: 10_000 })
1006
1007 return
1008 }
1009
1010 // What was typed is kept: the forge is asked about the request by it again,
1011 // for its comments and to post one.
1012 typed = to
1013 from = answer.side
1014 to = answer.against
1015 request = `${answer.label} → ${answer.target}${answer.title === '' ? '' : ` · ${answer.title}`}`
1016 }
1017
1018 const unknown = !(await known(from)) ? from : !(await known(to)) ? to : undefined
1019
1020 if (unknown !== undefined) {
1021 $.ui.toast(`"${unknown}" is not a branch or commit here`, { timeoutMs: 8000 })
1022
1023 return
1024 }
1025
1026 await update(
1027 $,
1028 view,
1029 (last): View => ({
1030 ...last,
1031 base: to,
1032 target: from,
1033 baseWorktree: beside,
1034 request,
1035 requestTyped: typed,
1036 isCommenting: false,
1037 commentLine: 0,
1038 screen: 'tree',
1039 isPicking: false,
1040 selected: '',
1041 commit: '',
1042 checked: [],
1043 }),
1044 )
1045 job = { isProject: false }
1046}
1047
1048// Switches the repo to a branch, or to a commit with HEAD detached. Where
1049// git refuses, the person reads git's own reason.
1050const checkOut = async (
1051 $: EngineInterface,
1052 repo: string,
1053 target: string,
1054 isBranch: boolean,
1055): Promise<void> => {
1056 // A branch another worktree has checked out cannot be checked out here
1057 // too (git refuses); its worktree is where it is, so the review goes there.
1058 const held = isBranch
1059 ? ((await read($, scan)).worktrees ?? []).find(one => one.branch === target && !one.isCurrent)
1060 : undefined
1061
1062 if (held !== undefined) {
1063 $.ui.toast(`${target} is checked out in the worktree ${held.path.split('/').pop() ?? ''}: reviewing it there`, {
1064 timeoutMs: 6000,
1065 })
1066 await openReview($, held.path, undefined)
1067
1068 return
1069 }
1070
1071 const refusal = await git.checkOut(runOf($), repo, target, isBranch)
1072
1073 if (refusal !== '') {
1074 $.ui.toast(`Not checked out: ${refusal}`, { timeoutMs: 10_000 })
1075
1076 return
1077 }
1078
1079 $.ui.toast(isBranch ? `Checked out ${target}` : `Checked out ${target} (detached HEAD)`)
1080 // A checkout starts over: no comparison, just what is modified in the tree
1081 // now checked out, until the person picks a commit to compare with again.
1082 await update(
1083 $,
1084 view,
1085 (last): View => ({
1086 ...last,
1087 base: 'HEAD',
1088 target: '',
1089 baseWorktree: '',
1090 screen: 'tree',
1091 selected: '',
1092 commit: '',
1093 }),
1094 )
1095 job = { isProject: false }
1096}
1097
1098// Reads the comments of the pull or merge request under review, and where in
1099// the repo the folder under review sits (a forge's paths are from the repo's
1100// root). Answers '' when it has them, else why not, in a sentence.
1101const loadComments = async ($: EngineInterface, repo: string, typed: string): Promise<string> => {
1102 const run = forgeRun(runOf($), repo)
1103 const [answer, prefix] = await Promise.all([fetchComments(run, typed), repoPrefix(run)])
1104
1105 if ('error' in answer) {
1106 return answer.error
1107 }
1108
1109 commentsCache = { key: `${repo}\n${typed}`, prefix, comments: answer.comments }
1110
1111 return ''
1112}
1113
1114// Posts one comment on a line of the request's head version of a file. It
1115// writes to the forge, so it runs only from the post button or Enter in the
1116// comment field, and says how it went.
1117const postReview = async (
1118 $: EngineInterface,
1119 repo: string,
1120 typed: string,
1121 target: string,
1122 path: string,
1123 line: number,
1124 body: string,
1125): Promise<void> => {
1126 if (body.trim() === '') {
1127 $.ui.toast('Type the comment first')
1128
1129 return
1130 }
1131
1132 const run = forgeRun(runOf($), repo)
1133 // With no target, the request is the checked-out branch's: its head is HEAD.
1134 const [head, prefix] = await Promise.all([
1135 git.fullHash(runOf($), repo, target === '' ? 'HEAD' : target),
1136 repoPrefix(run),
1137 ])
1138 const answer = await postComment(
1139 run,
1140 typed,
1141 { path: `${prefix}${path}`, line, commit: head },
1142 body.trim(),
1143 )
1144
1145 if ('error' in answer) {
1146 $.ui.toast(answer.error, { timeoutMs: 10_000 })
1147
1148 return
1149 }
1150
1151 if (commentsCache !== undefined && commentsCache.key === `${repo}\n${typed}`) {
1152 commentsCache.comments.push(answer.comment)
1153 }
1154
1155 commentDraft = ''
1156 commentRound += 1
1157 $.ui.toast(`Comment posted on line ${line}`)
1158 await update($, view, last => ({ ...last, commentLine: 0 }))
1159}
1160
1161// Answers the thread whose first comment is `root`, on the forge.
1162const postReply = async (
1163 $: EngineInterface,
1164 repo: string,
1165 typed: string,
1166 root: Comment,
1167 body: string,
1168): Promise<void> => {
1169 if (isFinding(root)) {
1170 $.ui.toast('A ledger finding has no thread to answer: send it to the prompt instead')
1171
1172 return
1173 }
1174
1175 if (body.trim() === '') {
1176 $.ui.toast('Type the reply first')
1177
1178 return
1179 }
1180
1181 const answer = await replyComment(forgeRun(runOf($), repo), typed, root, body.trim())
1182
1183 if ('error' in answer) {
1184 $.ui.toast(answer.error, { timeoutMs: 10_000 })
1185
1186 return
1187 }
1188
1189 if (commentsCache !== undefined && commentsCache.key === `${repo}\n${typed}`) {
1190 // The forge's own path and line for a reply may be missing; it sits
1191 // where the thread does.
1192 commentsCache.comments.push({ ...answer.comment, path: root.path, line: root.line })
1193 }
1194
1195 commentDraft = ''
1196 commentRound += 1
1197 $.ui.toast(`Replied to ${root.author}`)
1198 await update($, view, last => ({ ...last, commentLine: 0, replyTo: '' }))
1199}
1200hooks/check.ts 870 lines1// Checking: given the files of a change, what is wrong with them, and which
2// of it the change brought.
3//
4// Which tools exist is this module's own business. The language servers are
5// asked first and the command-line checkers (ruff, pyright, tsc, eslint,
6// terraform) cover what they could not; a file of any other language is
7// checked by its language server alone, when the bridge has one that is
8// installed. Each tool is run in the project its
9// files belong to, and the base's version of a file is checked in an export
10// of that commit, which git.ts makes. A caller sees diagnostics, notes on what could not run,
11// and the names of the tools still out, and nothing of how any of it is done.
12// The parsers are exported for their tests.
13
14import type { ChangedFile, Diag, Severity } from '../types'
15import { exportCommit, trackedFiles } from './git'
16import type { Run as ServerRun } from './lsp'
17import {
18 isOtherServed,
19 lspDiagnostics,
20 lspIsRunning,
21 lspServed,
22 servedTools,
23 unservedNotes,
24} from './lsp'
25import type { Run } from './run'
26import { FILE_LIMIT, tail } from './run'
27import type { Checkers } from './settings'
28
29const PYTHON = /\.pyi?$/
30const TYPESCRIPT = /\.[cm]?tsx?$/
31const TERRAFORM = /\.(tf|tfvars)$/i
32// The names the language servers' diagnostics carry as their tool.
33const SERVER_TOOLS = ['pyright', 'tsserver', 'terraform-ls', 'terraform']
34// What the language servers' own run is called while it is out.
35const SERVERS = 'language servers'
36
37// tsc and eslint start with `#!/usr/bin/env node`, and a Node installed
38// through nvm is not on the PATH of a process started outside a login shell.
39const WITH_NODE = [
40 'sh',
41 '-c',
42 'command -v node >/dev/null || for d in "$HOME"/.nvm/versions/node/*/bin; do [ -x "$d/node" ] && PATH="$d:$PATH"; done; exec "$@"',
43 'sh',
44]
45
46// For each file after the marker list, prints "file<tab>folder": the nearest
47// folder at or above the file that holds one of the markers, or "." for none.
48const NEAREST = `markers="$1"; shift
49for f in "$@"; do
50 d=$(dirname "$f")
51 while :; do
52 for m in $markers; do
53 if [ -e "$d/$m" ]; then printf '%s\\t%s\\n' "$f" "$d"; continue 3; fi
54 done
55 [ "$d" = "." ] && break
56 d=$(dirname "$d")
57 done
58 printf '%s\\t.\\n' "$f"
59done`
60
61// Whether the language servers are switched on, as the last scan had it: a
62// file of another language has no checker but its server.
63let areServersUsed = true
64
65// Whether a file of another language than the three above is checked: by its
66// language server, when one is installed and the servers are switched on.
67const isOther = (path: string): boolean => areServersUsed && isOtherServed(path)
68
69// Asks the bridge again which other languages it has an installed server for
70// (the screens ask `isCheckable` as they draw, and cannot wait for it), and
71// notes whether the servers are switched on. Resolves with what is wrong
72// with the person's config file; never rejects.
73export const refreshServed = async (servers: ServerRun, isUsed: boolean): Promise<string[]> => {
74 areServersUsed = isUsed
75
76 const notes = await lspServed(servers)
77
78 return isUsed ? notes : []
79}
80
81// One line for each language among `wanted` that would be checked were its
82// server installed, saying what to install.
83export const uncheckedNotes = (wanted: readonly string[]): string[] =>
84 areServersUsed ? unservedNotes(wanted) : []
85
86// Whether a file is of a kind some checker reads; one that is not has
87// nothing to say, which is not the same as being clean.
88export const isCheckable = (path: string): boolean =>
89 PYTHON.test(path) || TYPESCRIPT.test(path) || TERRAFORM.test(path) || isOther(path)
90
91// Whether a file is of a language whose server can search the project's names.
92export const hasNameSearch = (path: string): boolean =>
93 PYTHON.test(path) || TYPESCRIPT.test(path) || isOtherServed(path)
94
95// Whether a file's own checkers are among those still out: `pending` is the
96// tools a check has started and not finished, as `Progress` named them.
97export const isAwaited = (path: string, pending: readonly string[]): boolean =>
98 pending.some(tool =>
99 tool.startsWith(SERVERS)
100 ? isCheckable(path)
101 : PYTHON.test(path)
102 ? tool.startsWith('ruff') || tool.startsWith('pyright')
103 : TERRAFORM.test(path)
104 ? tool.startsWith('terraform')
105 : TYPESCRIPT.test(path) && (tool.startsWith('tsc') || tool.startsWith('eslint')),
106 )
107
108// The tools (as a diagnostic's `tool` names them) whose answers the pending
109// ones will bring: what they found last time is not yet replaced.
110export const toolsAwaited = (pending: readonly string[]): Set<string> =>
111 new Set(
112 pending.flatMap(tool =>
113 tool.startsWith(SERVERS) ? [...SERVER_TOOLS, ...servedTools()] : [tool.split(' ')[0] ?? ''],
114 ),
115 )
116
117// Told as each tool starts and finishes, with everything found so far, so a
118// caller can show results as they come in.
119export type Progress = {
120 start: (tool: string) => void
121 done: (tool: string, found: Diag[]) => void
122}
123
124export type Ports = {
125 run: Run
126 // How the language servers' bridge runs its commands.
127 servers: ServerRun
128 // A file's text by its absolute path; '' for one that cannot be read.
129 readFile: (path: string) => Promise<string>
130}
131
132// What to check: the files `wanted` (relative to `repo`) of a comparison of
133// `base` with the working tree or, when `target.ref` is not '', with that
134// commit. `short` is each side's short hash, '' where git knows none; `files`
135// is what differs between the sides. `isProject` asks for every file of
136// every project, changed or not.
137export type Ask = {
138 repo: string
139 base: { ref: string; short: string }
140 target: { ref: string; short: string }
141 files: readonly ChangedFile[]
142 wanted: readonly string[]
143 isProject: boolean
144 // The checkers the person has left switched on, and whether the base is
145 // checked too, to mark each diagnostic new or already there.
146 use: Checkers
147 marksNew: boolean
148}
149
150// What the checkers said about the base's version of the files last asked
151// about, and those files' lines there. Kept while the base and the files
152// stay the same, so an edit re-checks the working tree alone.
153let before: { key: string; diags: Diag[]; texts: Record<string, string[]> } | undefined
154
155// The same for the target commit, when the comparison is between two commits
156// (a commit does not change, so its diagnostics are kept until the commit or
157// the files asked about do).
158let after: { key: string; diags: Diag[] } | undefined
159
160// Checks the files asked about and marks each diagnostic as new or already
161// there (`isNew`), where the base could be checked too. Never rejects for a
162// tool that cannot run: `notes` says which could not, and why.
163export const checkChange = async (
164 ports: Ports,
165 { repo, base, target, files, wanted, isProject, use, marksNew }: Ask,
166 progress: Progress,
167): Promise<{ diags: Diag[]; notes: string[] }> => {
168 const notes: string[] = []
169 // A language with every one of its checkers switched off is not asked about.
170 const asked: Files = {
171 python: (use.ruff || use.pyright ? wanted.filter(path => PYTHON.test(path)) : []).slice(0, FILE_LIMIT),
172 typescript: (use.tsc || use.eslint ? wanted.filter(path => TYPESCRIPT.test(path)) : []).slice(
173 0,
174 FILE_LIMIT,
175 ),
176 terraform: (use.terraform ? wanted.filter(path => TERRAFORM.test(path)) : []).slice(0, FILE_LIMIT),
177 other: (use.servers ? wanted.filter(isOther) : []).slice(0, FILE_LIMIT),
178 }
179 // A whole-project check wants every file, which the command-line checkers
180 // give; anything else asks the language servers first.
181 const useServers = use.servers && (target.ref !== '' || !isProject)
182 // `side` is the tree the comparison reads and the checkers run over: the
183 // working tree, or, when two commits are compared, an export of the target.
184 let side = repo
185 let checked: Diag[] = []
186
187 if (target.ref === '') {
188 checked = await checkTree(ports, repo, repo, asked, isProject, useServers, use, notes, progress)
189 } else {
190 const sideKey = `${repo}\n${target.short}\n${JSON.stringify(use)}\n${[...asked.python, ...asked.typescript, ...asked.terraform, ...asked.other].join('\n')}`
191 const exported = await exportCommit(ports.run, repo, target.ref, target.short, base.short)
192
193 if (exported.dir === '') {
194 notes.push(
195 `diagnostics are not shown: ${target.ref} could not be exported to check it (${exported.refusal})`,
196 )
197 } else if (after?.key === sideKey) {
198 side = exported.dir
199 checked = after.diags
200 } else {
201 side = exported.dir
202 checked = await checkTree(ports, repo, side, asked, false, useServers, use, notes, progress)
203 after = { key: sideKey, diags: checked }
204 }
205 }
206
207 // New or already there: the same checkers run over the base's version of
208 // each modified file that has a diagnostic, and a diagnostic the base also
209 // had, on a line with the same text, was there before the change.
210 const statusOf = new Map(files.map(one => [one.path, one.status]))
211 const troubled = [...new Set(checked.map(diag => diag.path))]
212 .filter(path => statusOf.get(path) === 'M')
213 .sort()
214 const diags = checked.map(diag =>
215 statusOf.get(diag.path) === '?' || statusOf.get(diag.path) === 'A'
216 ? { ...diag, isNew: true }
217 : diag,
218 )
219
220 if (troubled.length === 0 || !marksNew) {
221 return { diags, notes }
222 }
223
224 const textsIn = async (tree: string): Promise<Record<string, string[]>> => {
225 const texts: Record<string, string[]> = {}
226
227 await Promise.all(
228 troubled.map(async path => {
229 texts[path] = (await ports.readFile(`${tree}/${path}`)).split('\n')
230 }),
231 )
232
233 return texts
234 }
235 const key = `${repo}\n${base.short}\n${useServers}\n${JSON.stringify(use)}\n${troubled.join('\n')}`
236
237 if (before?.key !== key && base.short !== '') {
238 // The base is checked last and all at once; until it is, what is new
239 // and what was there before is not known.
240 progress.start('new or pre-existing')
241
242 const exported = await exportCommit(
243 ports.run,
244 repo,
245 base.ref,
246 base.short,
247 target.short || base.short,
248 )
249
250 if (exported.dir === '') {
251 notes.push(
252 `new or pre-existing is not marked: ${base.ref} could not be exported to check it (${exported.refusal})`,
253 )
254 } else {
255 const missed: string[] = []
256 // The base is asked the same way as the side it is compared with: a
257 // diagnostic is matched by its tool and its words, and the servers
258 // and the command-line checkers do not put them the same.
259 const baseDiags = await checkTree(
260 ports,
261 repo,
262 exported.dir,
263 {
264 python: troubled.filter(path => PYTHON.test(path)),
265 typescript: troubled.filter(path => TYPESCRIPT.test(path)),
266 terraform: troubled.filter(path => TERRAFORM.test(path)),
267 other: use.servers ? troubled.filter(isOther) : [],
268 },
269 false,
270 useServers,
271 use,
272 missed,
273 )
274 const texts = await textsIn(exported.dir)
275
276 if (missed.length > 0) {
277 notes.push(`on the base, ${missed[0] ?? ''}`)
278 }
279
280 before = { key, diags: baseDiags, texts }
281 }
282 }
283
284 return {
285 diags: before?.key === key ? labelNew(diags, before.diags, await textsIn(side), before.texts) : diags,
286 notes,
287 }
288}
289
290// The files to check, by what checks them. `other` is every other language
291// the bridge has an installed server for: nothing but that server reads it.
292type Files = { python: string[]; typescript: string[]; terraform: string[]; other: string[] }
293
294// Runs the checkers over one tree of the repo's files: the working tree
295// itself, or an export of a commit. Projects, virtualenvs and node_modules
296// are always found in the working tree (`repo`), since an export has only
297// what git tracks; `sink` takes a line for each tool that could not run.
298// `isWhole` checks every file of every project with the command-line
299// checkers; otherwise, with `useServers`, the language servers are asked
300// first: they answer for the files given, in milliseconds once warm. The
301// `other` files are asked of the servers either way.
302const checkTree = async (
303 ports: Ports,
304 repo: string,
305 tree: string,
306 { python, typescript, terraform, other }: Files,
307 isWhole: boolean,
308 useServers: boolean,
309 use: Checkers,
310 sink: string[],
311 progress?: Progress,
312): Promise<Diag[]> => {
313 const found: Diag[] = []
314 // A server speaks in the name of the checker it stands in for; what one
315 // says for a checker that is switched off is left out.
316 const isWanted = (diag: Diag): boolean =>
317 diag.tool === 'pyright'
318 ? use.pyright
319 : diag.tool === 'tsserver'
320 ? use.tsc
321 : diag.tool.startsWith('terraform')
322 ? use.terraform
323 : true
324 // `dir` is a folder of the repo, relative to it: the project a tool runs in.
325 const at = (dir: string): string => (dir === '.' ? repo : `${repo}/${dir}`)
326 const inTree = (dir: string): string => (dir === '.' ? tree : `${tree}/${dir}`)
327 const under = (dir: string, path: string): string => (dir === '.' ? path : `${dir}/${path}`)
328 const run = (argv: string[], timeoutMs = 60_000, dir = '.') =>
329 ports.run(argv, { cwd: at(dir), timeoutMs })
330 const exec = (argv: string[], timeoutMs: number, dir = '.') =>
331 ports.run(argv, { cwd: inTree(dir), timeoutMs })
332 const has = async (path: string, dir = '.'): Promise<boolean> =>
333 (await run(['test', '-e', path], 60_000, dir)).exitCode === 0
334 // The projects the given files belong to, each with its files relative to it.
335 const projectsOf = async (markers: string, paths: string[]): Promise<Map<string, string[]>> =>
336 paths.length === 0
337 ? new Map()
338 : groupByRoot((await run(['sh', '-c', NEAREST, 'sh', markers, ...paths])).stdout)
339 // In a whole-project check, every project the repo tracks counts, changed or not.
340 const addTracked = async (projects: Map<string, string[]>, patterns: string[]): Promise<void> => {
341 for (const path of await trackedFiles(ports.run, repo, patterns)) {
342 const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.'
343
344 if (!projects.has(dir)) {
345 projects.set(dir, [])
346 }
347 }
348 }
349 const collect = (tool: string, parsed: Diag[] | undefined, stderr: string): void => {
350 if (parsed === undefined) {
351 sink.push(explain(tool, stderr))
352 } else {
353 found.push(...parsed)
354 }
355
356 progress?.done(tool, found)
357 }
358 const checks: Promise<void>[] = []
359
360 if (python.length > 0 && use.ruff) {
361 progress?.start('ruff')
362 checks.push(
363 exec(
364 ['uvx', 'ruff', 'check', '--output-format', 'json', '--exit-zero', ...python],
365 60_000,
366 ).then(ran => collect('ruff', parseRuff(ran.stdout, tree), ran.stderr)),
367 )
368 }
369
370 // Each Python project is checked from its own folder, with its own venv.
371 const pythonProjects = await projectsOf('pyrightconfig.json pyproject.toml .venv venv', python)
372
373 if (isWhole) {
374 await addTracked(pythonProjects, ['*pyproject.toml', '*pyrightconfig.json'])
375
376 if (pythonProjects.size === 0) {
377 pythonProjects.set('.', [])
378 }
379 }
380
381 // Each TypeScript project is the folder of the nearest tsconfig.json; its
382 // tools are the nearest node_modules at or above it (a workspace hoists them).
383 const typescriptProjects = await projectsOf('tsconfig.json', typescript)
384
385 if (isWhole) {
386 await addTracked(typescriptProjects, ['*tsconfig.json'])
387 }
388
389 const bins = new Map<string, string>()
390
391 for (const dir of typescriptProjects.keys()) {
392 const nearest = await run(['sh', '-c', NEAREST, 'sh', 'node_modules/.bin/tsc', under(dir, 'x')])
393 const binDir = [...groupByRoot(nearest.stdout).keys()][0] ?? '.'
394
395 bins.set(dir, `${at(binDir)}/node_modules/.bin`)
396
397 // An export has no node_modules of its own; it borrows the working
398 // tree's, so imports resolve there as they do here.
399 if (tree !== repo) {
400 await exec(
401 ['ln', '-sfn', `${at(binDir)}/node_modules`, `${inTree(binDir)}/node_modules`],
402 10_000,
403 )
404 }
405 }
406
407 // The language servers answer first. What they covered is not asked of
408 // the command-line type checkers again; what they could not cover (a
409 // server not installed, a file outside any project) still is.
410 const served = new Set<string>()
411 const asked = useServers && !isWhole ? [...python, ...typescript, ...terraform, ...other] : other
412
413 if (asked.length > 0) {
414 // Servers not yet running take their time over the first answer (the
415 // very first run fetches them), which is said while it lasts.
416 const servers = (await lspIsRunning(ports.servers))
417 ? SERVERS
418 : `${SERVERS} (starting: the first answer can take a minute)`
419
420 progress?.start(servers)
421
422 const answer = await lspDiagnostics(
423 ports.servers,
424 tree,
425 asked,
426 tree === repo ? {} : { envRoot: repo },
427 )
428
429 for (const path of answer.covered) {
430 served.add(path)
431 }
432
433 // A missing terraform-ls is not worth a note: the terraform command
434 // below checks those files, and says so itself when it cannot.
435 sink.push(...answer.notes.filter(note => !note.includes('terraform-ls')).map(explainServer))
436 found.push(...answer.diags.filter(isWanted))
437 progress?.done(servers, found)
438 }
439
440 // Terraform files no server answered for are validated by the terraform
441 // command, a folder at a time: a folder is one configuration.
442 const terraformDirs = new Set(
443 terraform
444 .filter(path => !served.has(path))
445 .map(path => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')),
446 )
447
448 for (const dir of terraformDirs) {
449 checks.push(
450 (async () => {
451 progress?.start(`terraform (${dir})`)
452
453 const ran = await exec(['terraform', 'validate', '-json', '-no-color'], 120_000, dir)
454 const parsed = parseTerraform(ran.stdout, dir)
455
456 sink.push(...(parsed?.notes ?? []))
457 collect(`terraform (${dir})`, parsed?.diags, ran.stderr)
458 })(),
459 )
460 }
461
462 for (const [dir, paths] of pythonProjects) {
463 const left = isWhole ? paths : paths.filter(path => !served.has(under(dir, path)))
464
465 if (!use.pyright || (!isWhole && left.length === 0)) {
466 continue
467 }
468
469 checks.push(
470 (async () => {
471 progress?.start(`pyright (${dir})`)
472
473 const [hasDotVenv, hasVenv] = await Promise.all([
474 has('.venv/bin/python', dir),
475 has('venv/bin/python', dir),
476 ])
477 const python3 = hasDotVenv
478 ? `${at(dir)}/.venv/bin/python`
479 : hasVenv
480 ? `${at(dir)}/venv/bin/python`
481 : undefined
482 const args = [
483 '--outputjson',
484 ...(python3 === undefined ? [] : ['--pythonpath', python3]),
485 ...(isWhole ? [] : left),
486 ]
487 const installed = await exec(['pyright', ...args], 300_000, dir)
488 // A pyenv shim only answers in a Python version that has pyright
489 // installed, and a project's .python-version may pin another one;
490 // uvx runs pyright whatever the folder's Python is.
491 const ran =
492 parsePyright(installed.stdout, tree) === undefined
493 ? await exec([...WITH_NODE, 'uvx', 'pyright', ...args], 300_000, dir)
494 : installed
495 collect(`pyright (${dir})`, parsePyright(ran.stdout, tree), ran.stderr)
496 })(),
497 )
498 }
499
500 for (const [dir, paths] of typescriptProjects) {
501 const bin = bins.get(dir) ?? `${at(dir)}/node_modules/.bin`
502 const needsTsc = use.tsc && (isWhole || paths.some(path => !served.has(under(dir, path))))
503
504 checks.push(
505 (async () => {
506 if (needsTsc && !(await has(`${bin}/tsc`))) {
507 // Packages can only be installed where something lists them.
508 const listed = await run(['sh', '-c', NEAREST, 'sh', 'package.json', under(dir, 'x')])
509 const packageDir = [...groupByRoot(listed.stdout).keys()][0] ?? '.'
510
511 sink.push(
512 (await has('package.json', packageDir))
513 ? `tsc skipped in ${here(dir)}: its packages are not installed. Run npm install (or pnpm, yarn, bun) there, then press r`
514 : `tsc skipped in ${here(dir)}: no package.json`,
515 )
516 } else if (needsTsc) {
517 progress?.start(`tsc (${dir})`)
518
519 // A solution-style tsconfig only lists references; the app's own
520 // config is the one that holds the source files.
521 const hasAppConfig = await has('tsconfig.app.json', dir)
522 const ran = await exec(
523 [
524 ...WITH_NODE,
525 `${bin}/tsc`,
526 ...(hasAppConfig ? ['-p', 'tsconfig.app.json'] : []),
527 '--noEmit',
528 '--pretty',
529 'false',
530 ],
531 300_000,
532 dir,
533 )
534 const parsed = parseTsc(ran.stdout).map(diag => ({
535 ...diag,
536 path: under(dir, diag.path),
537 }))
538 const isRun = !(ran.exitCode > 0 && parsed.length === 0)
539
540 // tsc speaks for the whole project, so what the server said of
541 // some of its files is dropped: one tool's word per project.
542 if (isRun) {
543 const prefix = dir === '.' ? '' : `${dir}/`
544
545 for (let at = found.length - 1; at >= 0; at -= 1) {
546 if (found[at]?.tool === 'tsserver' && (found[at]?.path ?? '').startsWith(prefix)) {
547 found.splice(at, 1)
548 }
549 }
550 }
551
552 collect(`tsc (${dir})`, isRun ? parsed : undefined, ran.stdout + ran.stderr)
553 }
554
555 if (paths.length === 0 || !use.eslint) {
556 return
557 }
558
559 // A project that does not use eslint is not missing anything.
560 if (!(await has(`${bin}/eslint`))) {
561 return
562 }
563
564 progress?.start(`eslint (${dir})`)
565
566 const ran = await exec(
567 [...WITH_NODE, `${bin}/eslint`, '-f', 'json', ...paths],
568 120_000,
569 dir,
570 )
571 collect(`eslint (${dir})`, parseEslint(ran.stdout, tree), ran.stderr)
572 })(),
573 )
574 }
575
576 await Promise.all(checks)
577
578 return found
579}
580
581// A project's folder as a person reads it.
582const here = (dir: string): string => (dir === '.' ? 'the folder under review' : dir)
583
584// Why a tool did not run, with what to do about it where the reason is one
585// of the usual ones; the tool's own last words otherwise. `tool` is the name
586// progress knows it by ("pyright (backend)").
587export const explain = (tool: string, stderr: string): string => {
588 const name = tool.split(' ')[0] ?? tool
589 const dir = /\((.*)\)$/.exec(tool)?.[1] ?? '.'
590 const said = stderr.toLowerCase()
591 const isMissing = /not found|no such file|enoent|cannot find|not installed/.test(said)
592
593 if (/timed? ?out|etimedout|sigterm|killed/.test(said)) {
594 return `${tool} was stopped: it took too long. Press r to try again; a first run can be slow while the tool is fetched`
595 }
596
597 if ((name === 'ruff' || name === 'pyright') && isMissing && /\buvx?\b/.test(said)) {
598 return `${tool} did not run: uv is not installed. Install it (brew install uv), then press r`
599 }
600
601 if (name === 'pyright' && isMissing && said.includes('node')) {
602 return `${tool} did not run: pyright needs Node, and none was found. Install Node (brew install node), then press r`
603 }
604
605 if ((name === 'tsc' || name === 'eslint') && isMissing && said.includes('node')) {
606 return `${tool} did not run: Node was not found. Install Node (brew install node, or nvm install), then press r`
607 }
608
609 if (name === 'eslint' && /eslint\.config|no eslint configuration|couldn't find a configuration/.test(said)) {
610 return `${tool} did not run: ${here(dir)} has no eslint config. Add an eslint.config.js there, or switch eslint off in /config`
611 }
612
613 if (name === 'terraform' && isMissing) {
614 return `${tool} did not run: terraform is not installed. Install it (brew install terraform), then press r`
615 }
616
617 return `${tool} did not run: ${tail(stderr) || 'it printed nothing'}. Switch it off in /config if this project does not use it`
618}
619
620// What the language servers' bridge said it could not do, with what to do
621// about it where that is known.
622export const explainServer = (note: string): string =>
623 /\buv\b.*(not found|not installed)|python3?: (command )?not found/i.test(note)
624 ? `${note}. The language servers need uv or python3 (brew install uv); the command-line checkers are used meanwhile`
625 : note
626
627// ---------------------------------------------------------------------------
628// How each tool prints what it found.
629// ---------------------------------------------------------------------------
630
631const relative = (path: string, root: string): string =>
632 path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
633
634const json = (out: string): unknown => {
635 try {
636 return JSON.parse(out)
637 } catch {
638 return undefined
639 }
640}
641
642// "file<tab>folder" lines into each folder's files, relative to that folder.
643export const groupByRoot = (out: string): Map<string, string[]> => {
644 const groups = new Map<string, string[]>()
645
646 for (const line of out.split('\n')) {
647 const [path, root] = line.split('\t')
648
649 if (path === undefined || root === undefined || path === '') {
650 continue
651 }
652
653 const inside = root === '.' ? path : path.slice(root.length + 1)
654 groups.set(root, [...(groups.get(root) ?? []), inside])
655 }
656
657 return groups
658}
659
660type RuffItem = {
661 code?: string | null
662 message?: string
663 filename?: string
664 location?: { row?: number; column?: number }
665 end_location?: { row?: number; column?: number }
666}
667
668export const parseRuff = (out: string, root: string): Diag[] | undefined => {
669 const items = json(out)
670
671 if (!Array.isArray(items)) {
672 return undefined
673 }
674
675 return (items as RuffItem[]).map(item => ({
676 path: relative(item.filename ?? '', root),
677 line: item.location?.row ?? 1,
678 col: item.location?.column ?? 1,
679 endCol: item.end_location?.row === item.location?.row ? (item.end_location?.column ?? 0) : 0,
680 severity: item.code ? 'warning' : 'error',
681 tool: 'ruff',
682 rule: item.code ?? 'syntax',
683 message: item.message ?? '',
684 }))
685}
686
687type PyrightItem = {
688 file?: string
689 severity?: string
690 message?: string
691 rule?: string
692 range?: {
693 start?: { line?: number; character?: number }
694 end?: { line?: number; character?: number }
695 }
696}
697
698const PYRIGHT_SEVERITY: Record<string, Severity> = {
699 error: 'error',
700 warning: 'warning',
701 information: 'info',
702}
703
704export const parsePyright = (out: string, root: string): Diag[] | undefined => {
705 const report = json(out) as { generalDiagnostics?: PyrightItem[] } | undefined
706
707 if (!Array.isArray(report?.generalDiagnostics)) {
708 return undefined
709 }
710
711 return report.generalDiagnostics.map(item => ({
712 path: relative(item.file ?? '', root),
713 line: (item.range?.start?.line ?? 0) + 1,
714 col: (item.range?.start?.character ?? 0) + 1,
715 endCol:
716 item.range?.end?.line === item.range?.start?.line ? (item.range?.end?.character ?? -1) + 1 : 0,
717 severity: PYRIGHT_SEVERITY[item.severity ?? ''] ?? 'info',
718 tool: 'pyright',
719 rule: item.rule ?? '',
720 message: item.message ?? '',
721 }))
722}
723
724type TerraformItem = {
725 severity?: string
726 summary?: string
727 detail?: string
728 range?: {
729 filename?: string
730 start?: { line?: number; column?: number }
731 end?: { line?: number; column?: number }
732 }
733}
734
735// `terraform validate -json`, run in `dir` (a folder of the repo, '.' for
736// the repo itself). A diagnostic that names no file (the folder needs
737// `terraform init`, say) is a note about the run, not about a line.
738export const parseTerraform = (
739 out: string,
740 dir: string,
741): { diags: Diag[]; notes: string[] } | undefined => {
742 const report = json(out) as { diagnostics?: TerraformItem[] } | undefined
743
744 if (!Array.isArray(report?.diagnostics)) {
745 return undefined
746 }
747
748 const said = (item: TerraformItem): string =>
749 [item.summary, item.detail].filter(part => part !== undefined && part !== '').join(': ')
750 const placed = report.diagnostics.filter(item => item.range?.filename !== undefined)
751
752 return {
753 diags: placed.map(item => ({
754 path: dir === '.' ? (item.range?.filename ?? '') : `${dir}/${item.range?.filename ?? ''}`,
755 line: item.range?.start?.line ?? 1,
756 col: item.range?.start?.column ?? 1,
757 endCol:
758 item.range?.end?.line === item.range?.start?.line ? (item.range?.end?.column ?? 0) : 0,
759 severity: item.severity === 'warning' ? 'warning' : 'error',
760 tool: 'terraform',
761 rule: '',
762 message: said(item),
763 })),
764 notes: report.diagnostics
765 .filter(item => item.range?.filename === undefined)
766 .map(item =>
767 // A folder never initialised has no providers to validate against.
768 /terraform init|not installed|missing required provider/i.test(said(item))
769 ? `terraform (${dir}) is not set up here: run terraform init in ${here(dir)}, then press r`
770 : `terraform (${dir}): ${said(item).replace(/\s+/g, ' ')}`,
771 ),
772 }
773}
774
775// `tsc --pretty false`: "src/a.ts(12,5): error TS2322: message".
776export const parseTsc = (out: string): Diag[] =>
777 out.split('\n').flatMap(line => {
778 const hit = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/.exec(line)
779
780 return hit === null
781 ? []
782 : [
783 {
784 path: hit[1] ?? '',
785 line: Number(hit[2]),
786 col: Number(hit[3]),
787 endCol: 0,
788 severity: hit[4] === 'error' ? 'error' : 'warning',
789 tool: 'tsc',
790 rule: hit[5] ?? '',
791 message: hit[6] ?? '',
792 } satisfies Diag,
793 ]
794 })
795
796type EslintFile = {
797 filePath?: string
798 messages?: {
799 line?: number
800 column?: number
801 endLine?: number
802 endColumn?: number
803 severity?: number
804 ruleId?: string | null
805 message?: string
806 }[]
807}
808
809export const parseEslint = (out: string, root: string): Diag[] | undefined => {
810 const files = json(out)
811
812 if (!Array.isArray(files)) {
813 return undefined
814 }
815
816 return (files as EslintFile[]).flatMap(file =>
817 (file.messages ?? []).map(message => ({
818 path: relative(file.filePath ?? '', root),
819 line: message.line ?? 1,
820 col: message.column ?? 1,
821 endCol: message.endLine === message.line ? (message.endColumn ?? 0) : 0,
822 severity: message.severity === 2 ? 'error' : 'warning',
823 tool: 'eslint',
824 rule: message.ruleId ?? '',
825 message: message.message ?? '',
826 })),
827 )
828}
829
830// Marks each diagnostic of the files checked on both sides as new or already
831// there. Line numbers shift as a file is edited, so one is matched to the
832// base's by tool, rule, message and the text of the line it sits on; each of
833// the base's stands for one match, so a second copy of an old problem is new.
834export const labelNew = (
835 head: readonly Diag[],
836 base: readonly Diag[],
837 headTexts: Readonly<Record<string, readonly string[]>>,
838 baseTexts: Readonly<Record<string, readonly string[]>>,
839): Diag[] => {
840 const keyOf = (diag: Diag, texts: Readonly<Record<string, readonly string[]>>): string =>
841 [
842 diag.path,
843 diag.tool,
844 diag.rule,
845 diag.message,
846 (texts[diag.path]?.[diag.line - 1] ?? '').trim(),
847 ].join('\u0001')
848 const left = new Map<string, number>()
849
850 for (const diag of base) {
851 const key = keyOf(diag, baseTexts)
852 left.set(key, (left.get(key) ?? 0) + 1)
853 }
854
855 return head.map(diag => {
856 if (headTexts[diag.path] === undefined) {
857 return diag
858 }
859
860 const key = keyOf(diag, headTexts)
861 const count = left.get(key) ?? 0
862
863 if (count > 0) {
864 left.set(key, count - 1)
865 }
866
867 return { ...diag, isNew: count === 0 }
868 })
869}
870hooks/diags.ts 39 lines1// Lists of diagnostics, read the ways the screens need: one file's, counted,
2// by the line they show under.
3
4import type { Diag } from '../types'
5
6export const diagsOf = (diags: readonly Diag[], path: string): Diag[] =>
7 diags
8 .filter(diag => diag.path === path)
9 .sort((a, b) => a.line - b.line || a.col - b.col)
10
11export const countLabel = (diags: readonly Diag[]): string => {
12 const errors = diags.filter(diag => diag.severity === 'error').length
13 const others = diags.length - errors
14
15 return [errors > 0 ? `${errors}✖` : '', others > 0 ? `${others}⚠` : '']
16 .filter(part => part !== '')
17 .join(' ')
18}
19
20// The file's diagnostics by the line they show under; one past the end of
21// the file shows under its last line. `diags` is the file's, sorted.
22export const diagsByLine = (
23 diags: readonly Diag[],
24 lineCount: number,
25): Map<number, { diag: Diag; index: number }[]> => {
26 const byLine = new Map<number, { diag: Diag; index: number }[]>()
27
28 diags.forEach((diag, index) => {
29 const line = Math.min(Math.max(1, diag.line), Math.max(1, lineCount))
30 byLine.set(line, [...(byLine.get(line) ?? []), { diag, index }])
31 })
32
33 return byLine
34}
35
36// Whether a diagnostic only says code is never used, at the level of a hint:
37// it fades that code and is no problem to count or list, as in an editor.
38export const isFadeOnly = (diag: Diag): boolean => diag.isUnused === true && diag.severity === 'info'
39hooks/git.ts 927 lines1// Everything that knows a git command line, or how git prints things: what
2// differs and where, the history and its lanes, blame, stashes, and the
3// commands that change the repo. Callers pass the `run` they hold and get
4// the pane's own values back; the parsers are exported for their tests.
5
6import type {
7 ChangedFile,
8 Commit,
9 GraphRow,
10 LineRange,
11 LineStat,
12 Picked,
13 Scan,
14 Span,
15} from '../types'
16import type { Run } from './run'
17import { FILE_LIMIT, tail } from './run'
18
19// `git diff --name-status`: "M\tpath", a rename "R100\told\tnew".
20export const parseNameStatus = (out: string): ChangedFile[] =>
21 out
22 .split('\n')
23 .filter(line => line.includes('\t'))
24 .map(line => {
25 const parts = line.split('\t')
26
27 return { path: parts[parts.length - 1] ?? '', status: line.charAt(0) }
28 })
29
30export const parseUntracked =(out: string): ChangedFile[] =>
31 out
32 .split('\n')
33 .filter(line => line !== '')
34 .map(path => ({ path, status: '?' }))
35
36// `git diff -U0 --no-prefix`: the added side of each hunk, per file.
37export const parseChangedLines = (out: string): Record<string, LineRange[]> => {
38 const changed: Record<string, LineRange[]> = {}
39 let path = ''
40
41 for (const line of out.split('\n')) {
42 if (line.startsWith('+++ ')) {
43 path = line.slice(4).trim()
44 continue
45 }
46
47 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
48 const count = hunk?.[2] === undefined ? 1 : Number(hunk[2])
49
50 if (hunk === null || path === '' || path === '/dev/null' || count === 0) {
51 continue
52 }
53
54 const from = Number(hunk[1])
55 changed[path] = [...(changed[path] ?? []), [from, from + count - 1]]
56 }
57
58 return changed
59}
60
61// One file's `git diff -U0`: the lines the base had and the working tree
62// dropped, keyed by the new file's line they come before. A hunk that only
63// removes names the line it follows, so its lines come before the next one.
64export const parseRemoved = (out: string): Record<number, string[]> => {
65 const removed: Record<number, string[]> = {}
66 let before = 0
67
68 for (const line of out.split('\n')) {
69 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
70
71 if (hunk !== null) {
72 before = Number(hunk[1]) + (hunk[2] === '0' ? 1 : 0)
73 } else if (before > 0 && line.startsWith('-') && !line.startsWith('---')) {
74 removed[before] = [
75 ...(removed[before] ?? []),
76 line.slice(1).replace(/\t/g, ' ').replace(/[\u0000-\u001f\u007f]/g, ''),
77 ]
78 }
79 }
80
81 return removed
82}
83
84export type BlameLine = { hash: string; author: string; time: number; summary: string }
85
86// `git blame --porcelain`: who last changed each line, in the file's order.
87// A commit's details are printed with its first line only, so they are kept
88// by hash for its later ones. A line not committed yet has an all-zero hash.
89export const parseBlame = (out: string): BlameLine[] => {
90 const known = new Map<string, BlameLine>()
91 const lines: BlameLine[] = []
92 let current: BlameLine | undefined
93
94 for (const line of out.split('\n')) {
95 const head = /^([0-9a-f]{40}) \d+ \d+/.exec(line)
96
97 if (head !== null) {
98 const hash = head[1] ?? ''
99
100 current = known.get(hash) ?? { hash, author: '', time: 0, summary: '' }
101 known.set(hash, current)
102 } else if (current === undefined) {
103 continue
104 } else if (line.startsWith('\t')) {
105 lines.push(current)
106 } else if (line.startsWith('author ')) {
107 current.author = line.slice(7)
108 } else if (line.startsWith('author-time ')) {
109 current.time = Number(line.slice(12))
110 } else if (line.startsWith('summary ')) {
111 current.summary = line.slice(8)
112 }
113 }
114
115 return lines
116}
117
118// How long ago, in the shortest form that still reads: 5m, 3h, 12d, 4mo, 2y.
119export const ageOf = (seconds: number): string => {
120 const steps: [size: number, unit: string][] = [
121 [31_536_000, 'y'],
122 [2_592_000, 'mo'],
123 [86_400, 'd'],
124 [3600, 'h'],
125 [60, 'm'],
126 ]
127 const step = steps.find(([size]) => seconds >= size)
128
129 return step === undefined ? 'now' : `${Math.floor(seconds / step[0])}${step[1]}`
130}
131
132// `git diff --numstat`: "added<tab>deleted<tab>path"; a binary file has "-".
133export const parseNumstat = (out: string): Record<string, LineStat> => {
134 const stats: Record<string, LineStat> = {}
135
136 for (const line of out.split('\n')) {
137 const [added, deleted, path] = line.split('\t')
138
139 if (path !== undefined && path !== '') {
140 stats[path] = [Number(added) || 0, Number(deleted) || 0]
141 }
142 }
143
144 return stats
145}
146
147// `wc -l` over untracked files: every line of a new file counts as added.
148export const parseLineCounts = (out: string): Record<string, LineStat> => {
149 const stats: Record<string, LineStat> = {}
150
151 for (const line of out.split('\n')) {
152 const hit = /^\s*(\d+)\s+(.+)$/.exec(line)
153
154 if (hit !== null && hit[2] !== 'total') {
155 stats[hit[2] ?? ''] = [Number(hit[1]), 0]
156 }
157 }
158
159 return stats
160}
161
162// `git log` with each commit's fields set off by \x01: hash, parents, refs,
163// subject, relative date, author.
164export const parseCommits = (out: string): Commit[] =>
165 out
166 .split('\n')
167 .filter(line => line !== '')
168 .map(line => {
169 const [hash = '', parents = '', refs = '', subject = '', when = '', author = ''] =
170 line.split('\u0001')
171
172 return {
173 hash,
174 parents: parents.split(' ').filter(parent => parent !== ''),
175 refs: refs.split(', ').filter(ref => ref !== ''),
176 subject,
177 when,
178 author,
179 }
180 })
181
182// The lane colours of VS Code's Git Graph, in its order.
183const LANE_COLORS = [
184 '#0085d9',
185 '#d9008f',
186 '#00d90a',
187 '#d98500',
188 '#a300d9',
189 '#ff0000',
190 '#00d9cc',
191 '#e138e8',
192 '#85d900',
193 '#dc5b23',
194 '#6f24d6',
195 '#ffcc00',
196]
197
198export const laneColor = (lane: number): string =>
199 LANE_COLORS[lane % LANE_COLORS.length] ?? '#0085d9'
200
201// Lays commits (newest first, children before parents) out in lanes, one row
202// each. A lane holds the hash it waits for; a commit takes the lane waiting
203// for it, closes the other lanes waiting for it (branches that started from
204// it), hands its lane to its first parent and opens or joins a lane for each
205// other parent (a merge).
206export const layoutGraph = (commits: readonly Commit[]): GraphRow[] => {
207 const lanes: (string | undefined)[] = []
208
209 return commits.map(commit => {
210 const before = [...lanes]
211 const waiting = before.indexOf(commit.hash)
212 const free = lanes.findIndex(held => held === undefined)
213 const lane = waiting !== -1 ? waiting : free !== -1 ? free : lanes.length
214 const closing = before.flatMap((held, at) => (held === commit.hash && at !== lane ? [at] : []))
215
216 for (const at of closing) {
217 lanes[at] = undefined
218 }
219
220 lanes[lane] = commit.parents[0]
221
222 const opening: number[] = []
223 const joining: number[] = []
224
225 for (const parent of commit.parents.slice(1)) {
226 const held = lanes.findIndex((one, at) => one === parent && at !== lane)
227
228 if (held !== -1) {
229 joining.push(held)
230 continue
231 }
232
233 const spare = lanes.findIndex((one, at) => one === undefined && at !== lane)
234 const at = spare !== -1 ? spare : lanes.length
235 lanes[at] = parent
236 opening.push(at)
237 }
238
239 while (lanes.length > 0 && lanes[lanes.length - 1] === undefined) {
240 lanes.pop()
241 }
242
243 const width = Math.max(before.length, lanes.length, lane + 1)
244 const reached = [...closing, ...opening, ...joining]
245 const cells: Span[] = []
246
247 const push = (color: string, text: string): void => {
248 const last = cells[cells.length - 1]
249
250 if (last !== undefined && last[0] === color) {
251 last[1] += text
252 } else {
253 cells.push([color, text])
254 }
255 }
256
257 for (let at = 0; at < width; at += 1) {
258 // A connector runs along the row from the commit's lane to each lane it reaches.
259 const crossing = reached.find(to => at > Math.min(lane, to) && at < Math.max(lane, to))
260 const filling = reached.find(to => at >= Math.min(lane, to) && at < Math.max(lane, to))
261 const glyph =
262 at === lane
263 ? '●'
264 : closing.includes(at)
265 ? at > lane
266 ? '╯'
267 : '╰'
268 : opening.includes(at)
269 ? at > lane
270 ? '╮'
271 : '╭'
272 : joining.includes(at)
273 ? at > lane
274 ? '┤'
275 : '├'
276 : before[at] !== undefined
277 ? '│'
278 : crossing !== undefined
279 ? '─'
280 : ' '
281
282 push(
283 glyph === ' ' ? '' : glyph === '─' && crossing !== undefined ? laneColor(crossing) : laneColor(at),
284 glyph,
285 )
286 push(filling === undefined ? '' : laneColor(filling), filling === undefined ? ' ' : '─')
287 }
288
289 // The lanes still open under this commit, drawn on a row of their own so
290 // each dot is joined to the next one down its lane.
291 const below: Span[] = []
292
293 lanes.forEach((held, at) => {
294 const color = held === undefined ? '' : laneColor(at)
295 const last = below[below.length - 1]
296
297 if (last !== undefined && last[0] === color) {
298 last[1] += held === undefined ? ' ' : '│ '
299 } else {
300 below.push([color, held === undefined ? ' ' : '│ '])
301 }
302 })
303
304 return { ...commit, cells, below, width: width * 2, lane }
305 })
306}
307
308export const sumStats = (stats: readonly LineStat[]): LineStat =>
309 stats.reduce<LineStat>((sum, one) => [sum[0] + one[0], sum[1] + one[1]], [0, 0])
310
311// ---------------------------------------------------------------------------
312// The commands. Each takes the `run` its caller holds and the folder under
313// review, and answers in the pane's own terms; none rejects.
314// ---------------------------------------------------------------------------
315
316// The most commits the graph reads. Only the rows in view are drawn, so the
317// limit is on what git prints and the module holds, not on the pane.
318export const GRAPH_COMMITS = 5000
319
320// The row that stands for what is not committed yet: a commit of the graph's
321// own making whose parent is the commit checked out, so it is laid out in a
322// lane that runs down to that commit like any other child of it.
323export const UNCOMMITTED = '*'
324const UNCOMMITTED_COLOR = '#8b949e'
325
326export type Stash = Scan['stashes'][number]
327
328// `git worktree list --porcelain`: a block per worktree, its folder first,
329// then its commit and the branch it has checked out (none when detached).
330export const parseWorktrees = (out: string): { path: string; branch: string; head: string }[] =>
331 out
332 .split(/\n\s*\n/)
333 .map(block => ({
334 path: /^worktree (.+)$/m.exec(block)?.[1] ?? '',
335 branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? '',
336 head: /^HEAD ([0-9a-f]+)$/m.exec(block)?.[1] ?? '',
337 }))
338 .filter(one => one.path !== '')
339
340// Exports a commit ($1, its short hash $2) of the folder it runs in to a
341// temporary folder and prints that folder's real path. A repo folder keeps
342// this export and the one named by $3 (the comparison's other side, when it
343// is a commit too); older ones are removed. Nothing in the repo or its .git
344// changes, which a `git worktree` would.
345const EXPORT_BASE = [
346 'root="${TMPDIR:-/tmp}/lens-base"',
347 'name=$(basename "$PWD")',
348 'dir="$root/$name-$2"',
349 'if [ ! -d "$dir" ]; then',
350 ' mkdir -p "$root" || exit 1',
351 // Only what is inside the folder: `find` would otherwise offer the folder
352 // itself, whose name a repo's can match (a repo called "lens", "lens-base").
353 ' find "$root" -mindepth 1 -maxdepth 1 -name "$name-*" ! -name "$name-$2" ! -name "$name-${3:-$2}" -exec rm -rf {} +',
354 ' mkdir "$dir.partial" && git archive "$1" | tar -x -C "$dir.partial" && mv "$dir.partial" "$dir" || exit 1',
355 'fi',
356 'cd "$dir" && pwd -P',
357].join('\n')
358
359const lines = (out: string): string[] => out.split('\n').filter(line => line !== '')
360
361// The real path of the folder a person named (`~` and all), when it is inside
362// a git repository; '' when it is not. A shell resolves it, and the folder
363// rides as an argument.
364export const findRepo = async (run: Run, folder: string): Promise<string> => {
365 const resolved = await run([
366 'sh',
367 '-c',
368 'case "$1" in "~"*) set -- "$HOME${1#\\~}";; esac; cd "$1" && git rev-parse --git-dir >/dev/null && pwd -P',
369 'sh',
370 folder,
371 ])
372
373 return resolved.exitCode !== 0 ? '' : resolved.stdout.trim()
374}
375
376// The history as the graph draws it: the uncommitted row first (where there
377// is anything uncommitted: `hasPending`), then the commits, laid out in lanes. The uncommitted row's lane is drawn grey down
378// to the commit checked out, where the branch's own colour takes over: its
379// cell is the first of each row until then.
380export const layoutHistory = (
381 commits: readonly Commit[],
382 checkedOut: string,
383 hasPending = true,
384): GraphRow[] => {
385 if (!hasPending) {
386 return layoutGraph([...commits])
387 }
388
389 const laidOut = layoutGraph([
390 {
391 hash: UNCOMMITTED,
392 parents: checkedOut === '' ? [] : [checkedOut],
393 refs: [],
394 subject: '',
395 when: '',
396 author: '',
397 },
398 ...commits,
399 ])
400 const headAt = laidOut.findIndex(row => row.hash === checkedOut)
401 const pendingColor = laneColor(laidOut[0]?.lane ?? 0)
402 const grey = (cells: Span[]): Span[] =>
403 cells.map((cell, at): Span =>
404 at === 0 && cell[0] === pendingColor ? [UNCOMMITTED_COLOR, cell[1]] : cell,
405 )
406
407 return laidOut.map((row, at) =>
408 at < headAt || (headAt === -1 && at === 0)
409 ? { ...row, cells: grey(row.cells), below: grey(row.below) }
410 : row,
411 )
412}
413
414// What git alone can say of a comparison: the files that differ and where,
415// their line counts, what is checked out, what is not committed, the stashes
416// and the whole history. `refusal` is git's reason when the comparison itself
417// could not be read, and absent when it was.
418export type Changes = {
419 files: ChangedFile[]
420 changed: Record<string, LineRange[]>
421 stats: Record<string, LineStat>
422 branches: string[]
423 head: string
424 headHash: string
425 dirty: string[]
426 stashes: Stash[]
427 worktrees: Scan['worktrees']
428 history: GraphRow[]
429 refusal?: string
430}
431
432// Reads the comparison of `base` with the working tree, or, when `target`
433// names a commit, of the two commits with each other: git then diffs the
434// pair, and nothing of the working tree (its untracked files, its edits)
435// counts.
436export const readChanges = async (
437 run: Run,
438 repo: string,
439 base: string,
440 target: string,
441): Promise<Changes> => {
442 const sides = target === '' ? [base] : [base, target]
443 const git = (argv: string[]) => run(['git', ...argv], { cwd: repo, timeoutMs: 60_000 })
444 const [named, untracked, hunks, numstat, log, branches, head, headHash, dirty, stashes, trees, top, prefix] =
445 await Promise.all([
446 git(['diff', '--name-status', '--relative', ...sides]),
447 git(['ls-files', '--others', '--exclude-standard']),
448 git(['diff', '-U0', '--no-prefix', '--relative', ...sides]),
449 git(['diff', '--numstat', '--no-renames', '--relative', ...sides]),
450 // Children before parents, which the lane layout relies on.
451 git([
452 'log',
453 '--branches',
454 'HEAD',
455 '--topo-order',
456 '-n',
457 String(GRAPH_COMMITS),
458 '--pretty=format:%h%x01%p%x01%D%x01%s%x01%ar%x01%an',
459 ]),
460 git(['branch', '--format=%(refname:short)']),
461 // The branch checked out, or "HEAD" when detached; and its commit.
462 git(['rev-parse', '--abbrev-ref', 'HEAD']),
463 git(['rev-parse', '--short', 'HEAD']),
464 // What is edited but not committed, whatever the base is.
465 git(['diff', '--name-only', '--relative', 'HEAD']),
466 // Each stash with its parents (the first is the commit it was made on)
467 // and how long ago.
468 git(['stash', 'list', '--format=%gd%x01%gs%x01%p%x01%ar']),
469 // The repo's worktrees, and where the folder under review sits in its own.
470 git(['worktree', 'list', '--porcelain']),
471 git(['rev-parse', '--show-toplevel']),
472 git(['rev-parse', '--show-prefix']),
473 ])
474 // Each worktree is reviewed at the same folder of it as this one is.
475 const inside = prefix.stdout.trim().replace(/\/$/, '')
476 const worktrees = parseWorktrees(trees.stdout).map(one => ({
477 path: inside === '' ? one.path : `${one.path}/${inside}`,
478 branch: one.branch,
479 head: one.head,
480 isCurrent: one.path === top.stdout.trim(),
481 }))
482 // A worktree kept inside the repo is a checkout of its own, not new files
483 // of this one: git lists its folder as untracked, and it is left out.
484 const nested = parseWorktrees(trees.stdout)
485 .map(one => one.path)
486 .filter(path => path !== top.stdout.trim())
487 const newFiles = parseUntracked(target === '' ? untracked.stdout : '').filter(one => {
488 const whole = `${repo}/${one.path}`.replace(/\/$/, '')
489
490 return !nested.some(path => whole === path || whole.startsWith(`${path}/`))
491 })
492 // git counts lines only for files it tracks; a new file's are all added.
493 const counted =
494 newFiles.length === 0
495 ? { stdout: '' }
496 : await run(
497 [
498 'sh',
499 '-c',
500 'wc -l -- "$@" 2>/dev/null',
501 'sh',
502 ...newFiles.map(one => one.path).slice(0, FILE_LIMIT),
503 ],
504 { cwd: repo, timeoutMs: 60_000 },
505 )
506
507 return {
508 files: [...parseNameStatus(named.stdout), ...newFiles],
509 changed: parseChangedLines(hunks.stdout),
510 stats: { ...parseLineCounts(counted.stdout), ...parseNumstat(numstat.stdout) },
511 branches: branches.stdout.split('\n').filter(name => name !== '' && !name.startsWith('(')),
512 head: head.stdout.trim(),
513 headHash: headHash.stdout.trim(),
514 dirty: target === '' ? lines(dirty.stdout) : [],
515 stashes: lines(stashes.stdout).map(line => {
516 const [ref = '', subject = '', parents = '', when = ''] = line.split('\u0001')
517
518 return { ref, subject, base: parents.split(' ')[0] ?? '', when }
519 }),
520 worktrees,
521 history: layoutHistory(
522 parseCommits(log.stdout),
523 headHash.stdout.trim(),
524 // A working tree with nothing edited and nothing new has no row of its own.
525 lines(dirty.stdout).length > 0 ||
526 parseUntracked(untracked.stdout).some(one => {
527 const whole = `${repo}/${one.path}`.replace(/\/$/, '')
528
529 return !nested.some(path => whole === path || whole.startsWith(`${path}/`))
530 }),
531 ),
532 ...(named.exitCode !== 0 ? { refusal: tail(named.stderr) } : {}),
533 }
534}
535
536// Prints a commit holding a worktree's files as they stand: what it has
537// checked out, with its uncommitted edits and its untracked files. The
538// commit is built through an index of its own and belongs to no branch, so
539// nothing of the worktree changes: not its files, its index or its HEAD.
540// Worktrees kept inside it are left out, being checkouts of their own.
541const SNAPSHOT = [
542 'top=$(git rev-parse --show-toplevel) && cd "$top" || exit 1',
543 'head=$(git rev-parse HEAD) || exit 1',
544 'index=$(mktemp) || exit 1',
545 'git worktree list --porcelain | sed -n "s/^worktree //p" > "$index.trees"',
546 'set -- .',
547 'while IFS= read -r tree; do',
548 ' case "$tree" in "$top"/*) set -- "$@" ":(exclude)${tree#"$top"/}";; esac',
549 'done < "$index.trees"',
550 'rm -f "$index" "$index.trees"',
551 'export GIT_INDEX_FILE="$index"',
552 'git read-tree HEAD && git add -A -- "$@" && tree=$(git write-tree)',
553 'status=$?',
554 'rm -f "$index"',
555 'unset GIT_INDEX_FILE',
556 '[ "$status" -eq 0 ] || exit 1',
557 'git -c user.name=lens -c user.email=lens@localhost commit-tree "$tree" -p "$head" -m "lens: a worktree as it stands"',
558].join('\n')
559
560// A commit of a worktree's files as they stand (see SNAPSHOT), by its hash;
561// '' with git's reason where it could not be made.
562export const snapshotWorktree = async (
563 run: Run,
564 path: string,
565): Promise<{ hash: string; refusal: string }> => {
566 const made = await run(['sh', '-c', SNAPSHOT], { cwd: path, timeoutMs: 120_000 })
567 const hash = made.stdout.trim()
568
569 return made.exitCode === 0 && /^[0-9a-f]{40,}$/.test(hash)
570 ? { hash, refusal: '' }
571 : { hash: '', refusal: tail(made.stderr) || 'git made no snapshot' }
572}
573
574// The repo a folder's worktree belongs to, by the path of its main
575// checkout; the folder's own repo root when it is that one. '' outside a repo.
576export const mainRepoOf = async (run: Run, repo: string): Promise<string> => {
577 const asked = await run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
578 cwd: repo,
579 timeoutMs: 20_000,
580 })
581
582 return asked.exitCode === 0 ? asked.stdout.trim().replace(/\/\.git\/?$/, '') : ''
583}
584
585// What the branch checked out has changed since it forked from the branch
586// its request targets: the commit it forked at ('' when git cannot say),
587// the files, and their line counts.
588export const requestChanges = async (
589 run: Run,
590 repo: string,
591 baseRef: string,
592): Promise<{ base: string; files: ChangedFile[]; stats: Record<string, LineStat> }> => {
593 const git = (argv: string[]) => run(['git', ...argv], { cwd: repo, timeoutMs: 60_000 })
594 // The remote's copy of the target is the truer one; a local branch may lag.
595 const remote = await git(['merge-base', 'HEAD', `origin/${baseRef}`])
596 const forked = remote.exitCode === 0 ? remote : await git(['merge-base', 'HEAD', baseRef])
597 const base = forked.exitCode === 0 ? forked.stdout.trim() : ''
598
599 if (base === '') {
600 return { base, files: [], stats: {} }
601 }
602
603 const [named, numstat] = await Promise.all([
604 git(['diff', '--name-status', '--relative', base, 'HEAD']),
605 git(['diff', '--numstat', '--no-renames', '--relative', base, 'HEAD']),
606 ])
607
608 return { base, files: parseNameStatus(named.stdout), stats: parseNumstat(numstat.stdout) }
609}
610
611// A short mark of how the repo stands: what is checked out, what is edited,
612// staged or new, the stashes and the worktrees. It changes when any of them
613// does, whoever made the change; '' where git does not answer.
614export const repoMark = async (run: Run, repo: string): Promise<string> => {
615 const asked = await run(
616 [
617 'sh',
618 '-c',
619 '{ git rev-parse HEAD; git symbolic-ref -q HEAD; git status --porcelain; git diff --numstat HEAD; git stash list; git worktree list; } 2>/dev/null | cksum',
620 ],
621 { cwd: repo, timeoutMs: 15_000 },
622 )
623
624 return asked.exitCode === 0 ? asked.stdout.trim() : ''
625}
626
627// The files git tracks in the repo, or only those matching the patterns.
628export const trackedFiles = async (
629 run: Run,
630 repo: string,
631 patterns: readonly string[] = [],
632): Promise<string[]> =>
633 lines(
634 (
635 await run(['git', 'ls-files', ...(patterns.length === 0 ? [] : ['--', ...patterns])], {
636 cwd: repo,
637 timeoutMs: 60_000,
638 })
639 ).stdout,
640 )
641
642// A ref's commit by its short hash, and in full; '' for a ref git does not know.
643export const shortHash = async (run: Run, repo: string, ref: string): Promise<string> =>
644 (await run(['git', 'rev-parse', '--short', ref], { cwd: repo, timeoutMs: 60_000 })).stdout.trim()
645
646export const fullHash = async (run: Run, repo: string, ref: string): Promise<string> =>
647 (await run(['git', 'rev-parse', ref], { cwd: repo, timeoutMs: 60_000 })).stdout.trim()
648
649// Whether a name is a branch, tag or commit here.
650export const isCommit = async (run: Run, repo: string, name: string): Promise<boolean> =>
651 (await run(['git', 'rev-parse', '--verify', '--quiet', `${name}^{commit}`], { cwd: repo }))
652 .exitCode === 0
653
654// A commit's files as a folder of their own, outside the repo: `dir` is its
655// real path, or '' with the reason in `refusal`. `short` names the folder;
656// the export of `keep` (the comparison's other side) is left in place, older
657// ones are removed.
658export const exportCommit = async (
659 run: Run,
660 repo: string,
661 ref: string,
662 short: string,
663 keep: string,
664): Promise<{ dir: string; refusal: string }> => {
665 const exported = await run(['sh', '-c', EXPORT_BASE, 'sh', ref, short, keep], {
666 cwd: repo,
667 timeoutMs: 120_000,
668 })
669 const dir = exported.stdout.trim()
670
671 return exported.exitCode !== 0 || dir === ''
672 ? { dir: '', refusal: tail(exported.stderr) || 'no export' }
673 : { dir, refusal: '' }
674}
675
676// A file as a commit left it; empty when the commit deleted it. A stash's
677// untracked files live in its third parent, so that is tried next.
678export const fileAt = async (
679 run: Run,
680 repo: string,
681 path: string,
682 commit: string,
683): Promise<string> => {
684 const shownAt = async (at: string): Promise<string | undefined> => {
685 const shown = await run(['git', 'show', `${at}:./${path}`], { cwd: repo })
686
687 return shown.exitCode === 0 ? shown.stdout : undefined
688 }
689
690 return (
691 (await shownAt(commit)) ??
692 (commit.startsWith('stash@') ? await shownAt(`${commit}^3`) : undefined) ??
693 ''
694 )
695}
696
697// What the diff view interleaves for one file: the lines the other side had
698// (`removed`), and, for a file read at a commit, the lines that commit's side
699// changed (the working tree's come with `readChanges`).
700//
701// The working tree's file (`commit` '') and a file read at the comparison's
702// own `target` are diffed against the base; at any other commit, against its
703// parent.
704export const fileDiff = async (
705 run: Run,
706 repo: string,
707 path: string,
708 commit: string,
709 base: string,
710 target: string,
711 // Whether the working tree's file wants its changed lines from this diff
712 // too: its base is then not the one the scan compared with.
713 isOwn = false,
714): Promise<{ removed: Record<number, string[]>; changed: LineRange[] | undefined }> => {
715 const diff = await run(
716 commit === ''
717 ? ['git', 'diff', '-U0', '--no-prefix', '--relative', base, '--', path]
718 : commit === target
719 ? ['git', 'diff', '-U0', '--no-prefix', '--relative', base, commit, '--', path]
720 : [
721 'git',
722 'show',
723 '--format=',
724 '-U0',
725 '--no-prefix',
726 '--relative',
727 '--diff-merges=first-parent',
728 commit,
729 '--',
730 path,
731 ],
732 { cwd: repo },
733 )
734
735 return {
736 removed: parseRemoved(diff.stdout),
737 changed:
738 commit === '' && !isOwn ? undefined : (parseChangedLines(diff.stdout)[path] ?? []),
739 }
740}
741
742// What a commit or a stash (`stash@{0}`) holds: its message and the files it
743// changed, with their line counts.
744export const commitDetails = async (run: Run, repo: string, hash: string): Promise<Picked> => {
745 // A stash keeps its untracked files in a commit of their own, which only
746 // `git stash show` knows to include.
747 const show = (format: string) =>
748 run(
749 hash.startsWith('stash@')
750 ? ['git', 'stash', 'show', '--include-untracked', '--no-renames', '--relative', format, hash]
751 : [
752 'git',
753 'show',
754 '--format=',
755 '--no-renames',
756 '--relative',
757 '--diff-merges=first-parent',
758 format,
759 hash,
760 ],
761 { cwd: repo },
762 )
763 // The graph's row has room for the subject alone; the rest of the message
764 // is read here and shown when the commit is opened.
765 const [named, counted, message] = await Promise.all([
766 show('--name-status'),
767 show('--numstat'),
768 run(['git', 'log', '-1', '--format=%s%n%b', hash], { cwd: repo }),
769 ])
770 const [subject = '', ...rest] = message.stdout.split('\n')
771
772 return {
773 hash,
774 files: parseNameStatus(named.stdout),
775 stats: parseNumstat(counted.stdout),
776 subject: subject.trim(),
777 body: rest.join('\n').trim(),
778 }
779}
780
781// Who last changed a line: the commit's short hash ('' for a line not
782// committed yet), its author, how long ago, and its subject.
783export type Blamed = { hash: string; author: string; age: string; summary: string }
784
785// Who last changed each line of a file, as a commit left it or (`commit` '')
786// as the working tree has it. A file git has never had committed has no
787// history to blame: every line of it is new, which an empty list stands for.
788// `now` is asked for the time, in milliseconds, once git has answered.
789export const blame = async (
790 run: Run,
791 repo: string,
792 path: string,
793 commit: string,
794 now: () => Promise<number>,
795): Promise<{ lines: Blamed[] } | { refusal: string }> => {
796 const ran = await run(
797 ['git', 'blame', '--porcelain', ...(commit === '' ? [] : [commit]), '--', path],
798 { cwd: repo, timeoutMs: 60_000 },
799 )
800
801 if (ran.exitCode !== 0) {
802 return /no such path/i.test(ran.stderr) ? { lines: [] } : { refusal: tail(ran.stderr) }
803 }
804
805 const seconds = (await now()) / 1000
806
807 return {
808 lines: parseBlame(ran.stdout).map(line => ({
809 hash: /^0+$/.test(line.hash) ? '' : line.hash.slice(0, 7),
810 author: line.author,
811 age: ageOf(Math.max(0, seconds - line.time)),
812 summary: line.summary,
813 })),
814 }
815}
816
817// The remote branches that already hold the commit checked out: none when it
818// has not been pushed.
819export const remotesWithHead = async (run: Run, repo: string): Promise<string[]> =>
820 (await run(['git', 'branch', '-r', '--contains', 'HEAD'], { cwd: repo })).stdout
821 .split('\n')
822 .map(line => line.trim())
823 .filter(line => line !== '')
824
825// The commands that change the repo answer '' when git did it, and otherwise
826// why not, in words fit to show: git's own last ones.
827const act = async (run: Run, repo: string, argv: string[]): Promise<string> => {
828 const ran = await run(['git', ...argv], { cwd: repo, timeoutMs: 120_000 })
829
830 return ran.exitCode !== 0
831 ? `git ${argv[0] ?? ''} did not go through: ${tail(ran.stderr || ran.stdout)}`
832 : ''
833}
834
835// Commits the given files and nothing else: each is added first, so a new or
836// deleted file is taken too, and the commit names them, so anything staged
837// beside them stays staged and uncommitted.
838export const commit = async (
839 run: Run,
840 repo: string,
841 paths: readonly string[],
842 message: string,
843 body: string,
844): Promise<string> => {
845 const added = await run(['git', 'add', '--', ...paths], { cwd: repo })
846
847 if (added.exitCode !== 0) {
848 return `git add did not go through: ${tail(added.stderr)}`
849 }
850
851 // A second -m is the commit's body, set off from the subject by a blank line.
852 return act(run, repo, [
853 'commit',
854 '-m',
855 message.trim(),
856 ...(body.trim() === '' ? [] : ['-m', body.trim()]),
857 '--',
858 ...paths,
859 ])
860}
861
862// Stashes the given files, the untracked ones among them included.
863export const stash = (
864 run: Run,
865 repo: string,
866 paths: readonly string[],
867 label: string,
868): Promise<string> =>
869 act(run, repo, ['stash', 'push', '--include-untracked', '-m', label, '--', ...paths])
870
871// Brings a stash back, keeping it (apply) or removing it (pop).
872export const restoreStash = (run: Run, repo: string, ref: string, isPop: boolean): Promise<string> =>
873 act(run, repo, ['stash', isPop ? 'pop' : 'apply', ref])
874
875// Takes the last commit back and keeps what it changed, as uncommitted edits.
876export const undoLastCommit = (run: Run, repo: string): Promise<string> =>
877 act(run, repo, ['reset', '--soft', 'HEAD~1'])
878
879// Throws away the uncommitted changes in the given files, which cannot be
880// undone: `tracked` go back to the last commit, `staged` (added but never
881// committed) and `untracked` are deleted. It stops at the first step git
882// refuses.
883export const discard = async (
884 run: Run,
885 repo: string,
886 tracked: readonly string[],
887 staged: readonly string[],
888 untracked: readonly string[],
889): Promise<string> => {
890 const steps = [
891 tracked.length > 0
892 ? ['restore', '--staged', '--worktree', '--source=HEAD', '--', ...tracked]
893 : undefined,
894 staged.length > 0 ? ['rm', '-f', '--quiet', '--', ...staged] : undefined,
895 untracked.length > 0 ? ['clean', '-f', '--quiet', '--', ...untracked] : undefined,
896 ]
897 let refusal = ''
898
899 for (const argv of steps) {
900 if (argv === undefined || refusal !== '') {
901 continue
902 }
903
904 const ran = await run(['git', ...argv], { cwd: repo })
905
906 if (ran.exitCode !== 0) {
907 refusal = `git ${argv[0] ?? ''} did not go through: ${tail(ran.stderr || ran.stdout)}`
908 }
909 }
910
911 return refusal
912}
913
914// Switches the repo to a branch, or to a commit with HEAD detached. Never
915// forced: where git refuses (local changes it would overwrite), the working
916// tree is left as it was and the answer is git's own reason.
917export const checkOut = async (
918 run: Run,
919 repo: string,
920 target: string,
921 isBranch: boolean,
922): Promise<string> => {
923 const ran = await run(['git', 'switch', ...(isBranch ? [] : ['--detach']), target], { cwd: repo })
924
925 return ran.exitCode !== 0 ? tail(ran.stderr) || 'git refused' : ''
926}
927hooks/lists.ts 164 lines1// What the language server answered, as the pane keeps it: lists of places
2// for the list screen (uses of a name, callers, implementations, names that
3// match), and the card of a looked-up name.
4
5import type { Comment } from './review'
6import type { Listing, ListRow, Lookup } from '../types'
7import type { LspSymbol } from './lsp'
8import type { CallNode, Place, PlaceLine, SymbolHit } from './lsp-types'
9import { ISSUES_SENT } from './prompt'
10import { groupPlaces, placesList, semanticColor } from './semantic'
11
12// The most rows the list screen draws, and so the most a list holds.
13export const LIST_ROWS = 300
14
15// The colour a kind of name has in the code, for a list of names: an outline
16// or a search names kinds as a symbol's, the colours are keyed by a token's.
17const KIND_TOKEN = new Map([
18 ['constant', 'variable'],
19 ['field', 'property'],
20 ['constructor', 'method'],
21 ['module', 'namespace'],
22])
23
24export const kindColor = (kind: string): string =>
25 semanticColor(KIND_TOKEN.get(kind) ?? kind, kind === 'constant' ? ['readonly'] : [])
26
27// Where a list entry points, as a row: an indented line that reads
28// "12: the line's text" under its file's heading.
29const placeRows = (places: readonly PlaceLine[], current: string): ListRow[] =>
30 groupPlaces(places, current).files.flatMap(group => [
31 { label: `${group.path} (${group.places.length})`, path: '', line: 0 },
32 ...group.places.map(place => ({
33 label: ` ${String(place.line).padStart(4)}: ${place.text}`,
34 path: place.path,
35 line: place.line,
36 })),
37 ])
38
39// Everywhere a name is used, the file it was asked about first; this list
40// also has a form for the prompt.
41export const usesList = (name: string, file: string, places: readonly PlaceLine[]): Listing => ({
42 title: `${places.length} uses of ${name}`,
43 rows: placeRows(places, file),
44 prompt: placesList(name, places, ISSUES_SENT),
45})
46
47// Who calls a function, or what it calls.
48export const callsList = (
49 name: string,
50 direction: 'incoming' | 'outgoing',
51 calls: readonly CallNode[],
52): Listing => ({
53 title: direction === 'incoming' ? `What calls ${name}` : `What ${name} calls`,
54 rows: calls.map(call => ({
55 label: `${call.name} ${call.place.path.split('/').slice(-2).join('/')}:${call.place.line} ${call.detail}`,
56 path: call.place.path,
57 line: call.place.line,
58 })),
59 prompt: '',
60})
61
62// What implements an interface, abstract method or protocol.
63export const implementationsList = (name: string, places: readonly Place[]): Listing => ({
64 title: `What implements ${name}`,
65 rows: places.map(place => ({
66 label: `${place.path}:${place.line}`,
67 path: place.path,
68 line: place.line,
69 })),
70 prompt: '',
71})
72
73// Names anywhere in the project that match what was typed, each with its
74// kind's mark and colour.
75export const namesList = (query: string, hits: readonly SymbolHit[]): Listing => ({
76 title: `Names matching "${query}"`,
77 rows: hits.map(hit => ({
78 label: hit.name,
79 mark: '›',
80 path: hit.place.path,
81 line: hit.place.line,
82 color: kindColor(hit.kind),
83 tail: `${hit.kind}${hit.container === '' ? '' : ` in ${hit.container}`} · ${hit.place.path.split('/').slice(-2).join('/')}`,
84 })),
85 prompt: '',
86})
87
88// What the server said of the name at a place in a file, as the file screen
89// shows it: its type and docs, where it is defined, and the call it sits in.
90export const lookupOf = (
91 file: string,
92 name: string,
93 line: number,
94 col: number,
95 answer: LspSymbol,
96): Lookup => ({
97 file,
98 name,
99 at: line,
100 col,
101 text: answer.text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ''),
102 path: answer.definition?.path ?? '',
103 line: answer.definition?.line ?? 0,
104 typePath: answer.typeDefinition?.path ?? '',
105 typeLine: answer.typeDefinition?.line ?? 0,
106 hasImplementations: (answer.implementations?.length ?? 0) > 0,
107 // The call the name sits in, with the argument being given marked.
108 signature:
109 answer.signature === undefined
110 ? ''
111 : answer.signature.parameters.length === 0
112 ? answer.signature.label
113 : `(${answer.signature.parameters.map((one, index) => (index === answer.signature?.active ? `[${one}]` : one)).join(', ')})`,
114})
115
116// A review comment in one line, for a list: where, who, and how it starts.
117const threadLine = (one: Comment, replies: number): string =>
118 `${one.path === '' ? '' : `${one.path}${one.line > 0 ? `:${one.line}` : ''} `}${one.author}: ${one.body.trim().replace(/\s+/g, ' ').slice(0, 90)}${replies > 0 ? ` (+${replies})` : ''}`
119
120// Every thread of a pull or merge request: the open ones first, then the
121// resolved, then what was said of the request as a whole. A thread is its
122// first comment; its replies are counted. The form for the prompt is the open
123// threads in full, replies and all: what is still to be answered.
124export const threadsList = (request: string, comments: readonly Comment[]): Listing => {
125 const roots = comments.filter(one => one.replyTo === undefined)
126 const repliesTo = (root: Comment): Comment[] => comments.filter(one => one.replyTo === root.id)
127 const placed = roots.filter(one => one.path !== '')
128 const open = placed.filter(one => one.isResolved !== true)
129 const settled = placed.filter(one => one.isResolved === true)
130 const general = roots.filter(one => one.path === '')
131 const rowsOf = (threads: readonly Comment[], mark: string): ListRow[] =>
132 threads.map(one => ({
133 label: `${mark}${threadLine(one, repliesTo(one).length)}`,
134 path: one.path,
135 line: Math.max(1, one.line),
136 }))
137
138 return {
139 title: `${request === '' ? 'Review' : request}: ${open.length} open, ${settled.length} resolved`,
140 rows: [
141 ...(open.length > 0 ? [{ label: `Open (${open.length})`, path: '', line: 0 }] : []),
142 ...rowsOf(open, ''),
143 ...(settled.length > 0 ? [{ label: `Resolved (${settled.length})`, path: '', line: 0 }] : []),
144 ...rowsOf(settled, '✓ '),
145 ...(general.length > 0
146 ? [{ label: `On the request as a whole (${general.length})`, path: '', line: 0 }]
147 : []),
148 ...general.map(one => ({ label: ` ${threadLine(one, 0)}`, path: '', line: 0 })),
149 ],
150 prompt:
151 open.length === 0
152 ? ''
153 : [
154 `Open review comments${request === '' ? '' : ` in ${request}`} (${open.length}):`,
155 ...open.flatMap(one => [
156 `- ${one.path}${one.line > 0 ? `:${one.line}` : ''} ${one.author}: ${one.body.trim().replace(/\s*\n\s*/g, ' ')}`,
157 ...repliesTo(one).map(
158 reply => ` - ${reply.author}: ${reply.body.trim().replace(/\s*\n\s*/g, ' ')}`,
159 ),
160 ]),
161 ].join('\n'),
162 }
163}
164hooks/lsp.ts 841 lines1// Diagnostics, symbol lookups and the other things an editor asks of its
2// language server (references, callers, outline, semantic tokens, a search by
3// name, inlay hints), from long-running language servers, through
4// the bridge in lsp-bridge.ts. Nothing here touches the engine: the caller hands in `run`
5// (its `$.process.run`), and every call is one short-lived command. The first
6// call writes the bridge script to a temp folder; the script's client mode
7// starts the daemon when it is not running, and the daemon keeps one server
8// per language and project until it has been idle for half an hour.
9import type { Diag, Severity } from '../types'
10import { LSP_BRIDGE_PY } from './lsp-bridge'
11import type {
12 CallNode,
13 InlayHint,
14 OutlineItem,
15 Place,
16 PlaceLine,
17 SemanticToken,
18 Signature,
19 SymbolHit,
20} from './lsp-types'
21
22export type Run = (
23 argv: string[],
24 init?: { cwd?: string; stdin?: string; timeoutMs?: number },
25) => Promise<{ exitCode: number; stdout: string; stderr: string }>
26
27export type LspOptions = {
28 // Where a project's virtualenv and node_modules are looked for when `repo`
29 // has none: the working tree, when `repo` is an export of a commit. The
30 // project's folder is the same path under it.
31 envRoot?: string
32 // How long the servers may take to answer, in milliseconds (two minutes).
33 timeoutMs?: number
34}
35
36export type LspResult = {
37 diags: Diag[]
38 // The files a server answered for; the rest still need another checker.
39 covered: string[]
40 // Why a language's server could not be used, one line each.
41 notes: string[]
42}
43
44// A diagnostic as the Language Server Protocol spells it: 0-based positions,
45// the end exclusive.
46export type LspDiagnostic = {
47 range?: {
48 start?: { line?: number; character?: number }
49 end?: { line?: number; character?: number }
50 }
51 severity?: number
52 code?: string | number
53 message?: string
54 // 1 is DiagnosticTag.Unnecessary: code that is never used.
55 tags?: number[]
56}
57
58type BridgeAnswer = {
59 ok?: boolean
60 error?: string
61 files?: Record<string, { tool?: string; diagnostics?: LspDiagnostic[] }>
62 notes?: string[]
63}
64
65// The files the bridge has a server for whatever else is installed: Python,
66// TypeScript and Terraform. `isServed` adds what its table of servers reads.
67export const LSP_FILE = /\.(pyi?|[cm]?tsx?|tf|tfvars)$/i
68
69// One server of the bridge's table (the built-in ones, and the person's own
70// from ~/.claude/lens/servers.json), as its `served` answer lists them.
71export type ServedBy = {
72 // Also the tool its diagnostics carry.
73 name: string
74 // What a person calls the language, for a note.
75 language: string
76 // In lower case, each with its dot.
77 extensions: string[]
78 // Whole file names, `*` and `?` standing for anything.
79 filenames: string[]
80 isInstalled: boolean
81 // How to get the server; '' when the table does not say.
82 install: string
83}
84
85// The table as the bridge last listed it, in the order a file is matched.
86// The screens ask about a file as they draw, with nothing to await, so the
87// answer is kept here and a scan asks again (`lspServed`). Empty until the
88// first answer: `LSP_FILE` alone is then what is served, as it was before
89// there was a table.
90let table: { server: ServedBy; names: RegExp[] }[] = []
91
92// A whole file name with `*` and `?` in it, as the bridge reads one.
93const namePattern = (name: string): RegExp =>
94 new RegExp(
95 `^${name
96 .replace(/[.+^$()|[\]{}\\]/g, '\\$&')
97 .replace(/\*/g, '.*')
98 .replace(/\?/g, '.')}$`,
99 )
100
101// The server that reads a file, as the bridge picks it: one that is installed
102// before one that is not, a file's whole name before its extension.
103const serverOf = (path: string): ServedBy | undefined => {
104 const name = path.slice(path.lastIndexOf('/') + 1)
105 const lower = name.toLowerCase()
106
107 for (const group of [table.filter(one => one.server.isInstalled), table]) {
108 const found =
109 group.find(one => one.names.some(pattern => pattern.test(name))) ??
110 group.find(one => one.server.extensions.some(extension => lower.endsWith(extension)))
111
112 if (found !== undefined) {
113 return found.server
114 }
115 }
116
117 return undefined
118}
119
120// Whether a file that is not Python, TypeScript or Terraform is read by a
121// server of the table that is installed.
122export const isOtherServed = (path: string): boolean =>
123 !LSP_FILE.test(path) && serverOf(path)?.isInstalled === true
124
125// Whether the bridge has a server for a file.
126export const isServed = (path: string): boolean => LSP_FILE.test(path) || isOtherServed(path)
127
128// The names of the table's installed servers: the tools their diagnostics carry.
129export const servedTools = (): string[] =>
130 table.filter(one => one.server.isInstalled).map(one => one.server.name)
131
132// For the files a server of the table would read were it installed: one line
133// for each such server, saying what to install.
134export const unservedNotes = (paths: readonly string[]): string[] => {
135 const notes = new Set<string>()
136
137 for (const path of paths) {
138 const server = LSP_FILE.test(path) ? undefined : serverOf(path)
139
140 if (server !== undefined && !server.isInstalled) {
141 notes.add(
142 `${server.language} is not checked: ${server.name} is not installed${server.install === '' ? '' : ` (${server.install})`}`,
143 )
144 }
145 }
146
147 return [...notes]
148}
149
150// DiagnosticTag.Unnecessary.
151const UNNECESSARY = 1
152
153const SEVERITY: Record<number, Severity> = { 1: 'error', 2: 'warning', 3: 'info', 4: 'info' }
154
155// One protocol diagnostic of `path`, as the pane keeps them: 1-based, and
156// `endCol` only when the range ends on the line it starts on.
157export const toDiag = (path: string, tool: string, item: LspDiagnostic): Diag => {
158 const start = item.range?.start
159 const end = item.range?.end
160 const line = start?.line ?? 0
161
162 const diag: Diag = {
163 path,
164 line: line + 1,
165 col: (start?.character ?? 0) + 1,
166 endCol: end?.line === line && end.character !== undefined ? end.character + 1 : 0,
167 severity: SEVERITY[item.severity ?? 1] ?? 'info',
168 tool,
169 rule: item.code === undefined ? '' : String(item.code),
170 message: item.message ?? '',
171 }
172
173 if (Array.isArray(item.tags) && item.tags.includes(UNNECESSARY)) {
174 diag.isUnused = true
175 }
176
177 return diag
178}
179
180const byPlace = (a: Diag, b: Diag): number =>
181 a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line || a.col - b.col
182
183const failed = (why: string): LspResult => ({
184 diags: [],
185 covered: [],
186 notes: [`language servers did not answer: ${why}`],
187})
188
189const lastLine = (text: string): string => text.trim().split('\n').pop()?.slice(0, 200) ?? ''
190
191// What the bridge's client printed, as diagnostics. Anything but its JSON
192// answer (the command failed, or its output was cut) covers no file and says
193// why in a note, so every file falls back to the other checkers.
194export const parseBridge = (stdout: string, stderr = ''): LspResult => {
195 let answer: BridgeAnswer | undefined
196
197 try {
198 answer = JSON.parse(stdout) as BridgeAnswer
199 } catch {
200 answer = undefined
201 }
202
203 if (answer === undefined || answer === null || typeof answer !== 'object') {
204 return failed(lastLine(stderr) || lastLine(stdout) || 'no output')
205 }
206
207 if (answer.ok !== true) {
208 return failed(answer.error ?? 'no reason given')
209 }
210
211 const diags: Diag[] = []
212 const covered: string[] = []
213
214 for (const [path, file] of Object.entries(answer.files ?? {})) {
215 covered.push(path)
216
217 for (const item of file.diagnostics ?? []) {
218 diags.push(toDiag(path, file.tool ?? '', item))
219 }
220 }
221
222 return {
223 diags: diags.sort(byPlace),
224 covered: covered.sort(),
225 notes: (answer.notes ?? []).map(String),
226 }
227}
228
229// Writes the script on stdin to this user's bridge folder (the socket, lock
230// and log live beside it) and prints its path. Written under another name
231// first, so a client never reads half a script.
232const INSTALL = [
233 'd="/tmp/lens-lsp-$(id -u)"',
234 'mkdir -p -m 700 "$d" && [ -O "$d" ] || exit 1',
235 'cat > "$d/bridge.py.$$" && mv "$d/bridge.py.$$" "$d/bridge.py" && printf %s "$d/bridge.py"',
236].join('\n')
237
238// uv picks a Python whatever the folder pins; the system's is the fallback.
239const PYTHON =
240 'command -v uv >/dev/null 2>&1 && exec uv run --no-project python "$@"; exec python3 "$@"'
241
242// The script's path, per `run` it was written with: a caller that keeps one
243// `run` writes the script once per load of the module, and one that makes a
244// new `run` each time writes it each time (a few milliseconds). A script that
245// differs from the one a running daemon was started from makes the client
246// replace that daemon, so an update takes effect on the next call.
247const installed = new WeakMap<Run, Promise<string>>()
248
249const install = (run: Run): Promise<string> => {
250 const known = installed.get(run)
251
252 if (known !== undefined) {
253 return known
254 }
255
256 const writing = run(['sh', '-c', INSTALL], { stdin: LSP_BRIDGE_PY, timeoutMs: 10_000 }).then(
257 ran => {
258 const path = ran.stdout.trim()
259
260 if (ran.exitCode !== 0 || !path.endsWith('/bridge.py')) {
261 throw new Error(`could not write the bridge script: ${lastLine(ran.stderr) || 'no output'}`)
262 }
263
264 return path
265 },
266 )
267 installed.set(run, writing)
268 // A failure is not remembered: the next call tries again.
269 writing.catch(() => {
270 installed.delete(run)
271 })
272
273 return writing
274}
275
276const bridge = async (
277 run: Run,
278 mode: 'query' | 'symbol' | 'ask' | 'served' | 'stop',
279 request: object,
280 timeoutMs: number,
281): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
282 const script = await install(run)
283
284 return run(['sh', '-c', PYTHON, 'sh', script, mode], {
285 cwd: script.slice(0, script.lastIndexOf('/')),
286 stdin: JSON.stringify(request),
287 timeoutMs,
288 })
289}
290
291// Diagnostics for `files` (paths relative to `repo`, an absolute real path)
292// from the language servers that cover them. Never rejects: a bridge that
293// cannot run covers nothing and says why in `notes`.
294export const lspDiagnostics = async (
295 run: Run,
296 repo: string,
297 files: readonly string[],
298 options: LspOptions = {},
299): Promise<LspResult> => {
300 const wanted = files.filter(isServed)
301
302 if (wanted.length === 0) {
303 return { diags: [], covered: [], notes: [] }
304 }
305
306 const timeoutMs = options.timeoutMs ?? 120_000
307
308 try {
309 const ran = await bridge(
310 run,
311 'query',
312 { repo, files: wanted, envRoot: options.envRoot, timeout: timeoutMs / 1000 },
313 // The client gives the daemon a little longer than the servers get.
314 Math.min(timeoutMs + 45_000, 600_000),
315 )
316
317 return parseBridge(ran.stdout, ran.stderr)
318 } catch (error) {
319 return failed(error instanceof Error ? error.message : String(error))
320 }
321}
322
323const stringsOf = (value: unknown): string[] =>
324 Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
325
326// What the bridge's client printed for `served`: its table and its notes, or
327// undefined for anything but its JSON answer.
328export const parseServed = (stdout: string): { servers: ServedBy[]; notes: string[] } | undefined => {
329 let answer: { ok?: unknown; servers?: unknown; notes?: unknown } | null | undefined
330
331 try {
332 answer = JSON.parse(stdout) as typeof answer
333 } catch {
334 answer = undefined
335 }
336
337 if (answer === undefined || answer === null || answer.ok !== true || !Array.isArray(answer.servers)) {
338 return undefined
339 }
340
341 const servers: ServedBy[] = []
342
343 for (const one of answer.servers as Partial<Record<keyof ServedBy, unknown>>[]) {
344 if (one !== null && typeof one === 'object' && typeof one.name === 'string' && one.name !== '') {
345 servers.push({
346 name: one.name,
347 language: typeof one.language === 'string' && one.language !== '' ? one.language : one.name,
348 extensions: stringsOf(one.extensions).map(extension => extension.toLowerCase()),
349 filenames: stringsOf(one.filenames),
350 isInstalled: one.isInstalled === true,
351 install: typeof one.install === 'string' ? one.install : '',
352 })
353 }
354 }
355
356 return { servers, notes: stringsOf(answer.notes) }
357}
358
359// Asks the bridge for its table of servers and keeps it, for `isServed` and
360// the rest above to answer from. Resolves with what is wrong with the
361// person's config file, one line for each entry left out. Never rejects: a
362// bridge that cannot answer leaves the table as it was, and says nothing
363// (the next thing asked of it says why).
364export const lspServed = async (run: Run): Promise<string[]> => {
365 try {
366 const answer = parseServed((await bridge(run, 'served', {}, 20_000)).stdout)
367
368 if (answer === undefined) {
369 return []
370 }
371
372 table = answer.servers.map(server => ({ server, names: server.filenames.map(namePattern) }))
373
374 return answer.notes
375 } catch {
376 return []
377 }
378}
379
380// Whether the servers' keeper is already up: one that is not has to start,
381// and its servers with it, before the first answer comes.
382export const lspIsRunning = async (run: Run): Promise<boolean> =>
383 run(['sh', '-c', 'test -S "/tmp/lens-lsp-$(id -u)/bridge.sock"'], { timeoutMs: 5000 }).then(
384 ran => ran.exitCode === 0,
385 () => false,
386 )
387
388// Stops the servers of the projects at or under `root` (an export that is
389// about to be deleted), or, with no root, the daemon and every server. A
390// daemon that is not running is left that way.
391export const stopLsp = async (run: Run, root?: string): Promise<void> => {
392 try {
393 await bridge(run, 'stop', root === undefined ? {} : { root }, 40_000)
394 } catch {
395 // Nothing to stop, or nothing to stop it with.
396 }
397}
398
399// What a language server knows about the symbol at one place.
400export type LspSymbol = {
401 // Plain text: the signature or type as the server writes it, then a blank
402 // line and the docs when there are any. '' when the server has nothing.
403 text: string
404 // Where the symbol is declared, when the server can say: 1-based, and
405 // `path` relative to the repo when it is inside it, else absolute.
406 definition?: { path: string; line: number; col: number; isInRepo: boolean }
407 // The call the place is inside the arguments of, when it is in one.
408 signature?: Signature
409 // Where the symbol's type is declared, when that is not `definition`.
410 typeDefinition?: Place
411 // What implements it, for an interface, an abstract method or a protocol
412 // (at most 50). Left out when the server knows of none.
413 implementations?: Place[]
414 // Why nothing came back: no server for the language, not installed, timed
415 // out. Empty when the server answered, even with nothing (no symbol there).
416 notes: string[]
417}
418
419type SymbolAnswer = {
420 ok?: boolean
421 error?: string
422 text?: unknown
423 definition?: { path?: unknown; line?: unknown; col?: unknown; isInRepo?: unknown }
424 signature?: unknown
425 typeDefinition?: unknown
426 implementations?: unknown
427 notes?: unknown[]
428}
429
430const noSymbol = (why: string): LspSymbol => ({
431 text: '',
432 notes: [`language server did not answer: ${why}`],
433})
434
435const isPlace = (value: unknown): value is number =>
436 typeof value === 'number' && Number.isInteger(value) && value >= 1
437
438const fields = (value: unknown): Record<string, unknown> | undefined =>
439 value !== null && typeof value === 'object' && !Array.isArray(value)
440 ? (value as Record<string, unknown>)
441 : undefined
442
443const listOf = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
444
445const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
446
447// A whole place of an answer, or undefined when a part of it is missing.
448const toPlace = (value: unknown): Place | undefined => {
449 const at = fields(value)
450
451 if (
452 at === undefined ||
453 typeof at.path !== 'string' ||
454 at.path === '' ||
455 !isPlace(at.line) ||
456 !isPlace(at.col)
457 ) {
458 return undefined
459 }
460
461 return { path: at.path, line: at.line, col: at.col, isInRepo: at.isInRepo === true }
462}
463
464const toSignature = (value: unknown): Signature | undefined => {
465 const found = fields(value)
466
467 if (found === undefined || typeof found.label !== 'string') {
468 return undefined
469 }
470
471 const parameters = listOf(found.parameters).map(textOf)
472 const active = found.active
473
474 return {
475 label: found.label,
476 parameters,
477 active:
478 typeof active === 'number' && Number.isInteger(active) && active < parameters.length
479 ? Math.max(active, -1)
480 : -1,
481 docs: textOf(found.docs),
482 }
483}
484
485// What the bridge's client printed for a symbol request. Anything but its JSON
486// answer has no text and says why in one note.
487export const parseSymbol = (stdout: string, stderr = ''): LspSymbol => {
488 let answer: SymbolAnswer | undefined
489
490 try {
491 answer = JSON.parse(stdout) as SymbolAnswer
492 } catch {
493 answer = undefined
494 }
495
496 if (answer === undefined || answer === null || typeof answer !== 'object') {
497 return noSymbol(lastLine(stderr) || lastLine(stdout) || 'no output')
498 }
499
500 if (answer.ok !== true) {
501 return noSymbol(answer.error ?? 'no reason given')
502 }
503
504 const found: LspSymbol = {
505 text: typeof answer.text === 'string' ? answer.text : '',
506 notes: (answer.notes ?? []).map(String),
507 }
508 const at = answer.definition
509
510 if (
511 at !== undefined &&
512 at !== null &&
513 typeof at.path === 'string' &&
514 at.path !== '' &&
515 isPlace(at.line) &&
516 isPlace(at.col)
517 ) {
518 found.definition = { path: at.path, line: at.line, col: at.col, isInRepo: at.isInRepo === true }
519 }
520
521 const signature = toSignature(answer.signature)
522 const typeDefinition = toPlace(answer.typeDefinition)
523 const implementations: Place[] = []
524
525 for (const item of listOf(answer.implementations)) {
526 const place = toPlace(item)
527
528 if (place !== undefined) {
529 implementations.push(place)
530 }
531 }
532
533 if (signature !== undefined) {
534 found.signature = signature
535 }
536
537 if (typeDefinition !== undefined) {
538 found.typeDefinition = typeDefinition
539 }
540
541 if (implementations.length > 0) {
542 found.implementations = implementations
543 }
544
545 return found
546}
547
548// What the language server knows about the symbol at a position: 1-based line
549// and column in `file`, a path relative to `repo` (an absolute real path). The
550// column counts the line's own characters as a string index does (UTF-16 code
551// units; a tab is one). Uses the daemon and servers the diagnostics use, and
552// opens the file in its server when it is not open yet. Never rejects: any
553// failure is `text: ''` and one note. `options.timeoutMs` is how long the
554// server may take (30 seconds when not given). Inside a call's arguments the
555// answer has that call's `signature`; `typeDefinition` and `implementations`
556// are there when the server has them (pyright has no implementations).
557export const lspSymbol = async (
558 run: Run,
559 repo: string,
560 file: string,
561 line: number,
562 col: number,
563 options: LspOptions = {},
564): Promise<LspSymbol> => {
565 if (!isServed(file)) {
566 return { text: '', notes: ['no language server for this kind of file'] }
567 }
568
569 const timeoutMs = options.timeoutMs ?? 30_000
570
571 try {
572 const ran = await bridge(
573 run,
574 'symbol',
575 { repo, file, line, col, envRoot: options.envRoot, timeout: timeoutMs / 1000 },
576 Math.min(timeoutMs + 45_000, 600_000),
577 )
578
579 return parseSymbol(ran.stdout, ran.stderr)
580 } catch (error) {
581 return noSymbol(error instanceof Error ? error.message : String(error))
582 }
583}
584
585// What the bridge's client printed for one of the lookups below: its answer's
586// fields and notes. Anything but its JSON answer is no fields and one note.
587type Asked = { answer: Record<string, unknown>; notes: string[] }
588
589const unanswered = (why: string): Asked => ({
590 answer: {},
591 notes: [`language server did not answer: ${why}`],
592})
593
594const parseAsked = (stdout: string, stderr: string): Asked => {
595 let parsed: unknown
596
597 try {
598 parsed = JSON.parse(stdout)
599 } catch {
600 parsed = undefined
601 }
602
603 const answer = fields(parsed)
604
605 if (answer === undefined) {
606 return unanswered(lastLine(stderr) || lastLine(stdout) || 'no output')
607 }
608
609 if (answer.ok !== true) {
610 return unanswered(typeof answer.error === 'string' ? answer.error : 'no reason given')
611 }
612
613 return { answer, notes: listOf(answer.notes).map(String) }
614}
615
616// One lookup in the server of `file`, with the daemon and servers everything
617// else here uses. Never rejects. The server may take `options.timeoutMs` (30
618// seconds when not given).
619const ask = async (
620 run: Run,
621 what: 'references' | 'calls' | 'outline' | 'tokens' | 'symbols' | 'hints',
622 file: string,
623 request: object,
624 options: LspOptions,
625): Promise<Asked> => {
626 if (!isServed(file)) {
627 return { answer: {}, notes: ['no language server for this kind of file'] }
628 }
629
630 const timeoutMs = options.timeoutMs ?? 30_000
631
632 try {
633 const ran = await bridge(
634 run,
635 'ask',
636 { what, ...request, envRoot: options.envRoot, timeout: timeoutMs / 1000 },
637 Math.min(timeoutMs + 45_000, 600_000),
638 )
639
640 return parseAsked(ran.stdout, ran.stderr)
641 } catch (error) {
642 return unanswered(error instanceof Error ? error.message : String(error))
643 }
644}
645
646const isCount = (value: unknown): value is number =>
647 typeof value === 'number' && Number.isInteger(value) && value >= 0
648
649// Everywhere the symbol at a place is used, its declaration included, each
650// with the text of its line: the project's own files first, then installed
651// packages, each in path and line order. At most 1,000 (a note says how many
652// there were). The place is 1-based, as for `lspSymbol`. A place with no symbol
653// is no places and no note.
654export const lspReferences = async (
655 run: Run,
656 repo: string,
657 file: string,
658 line: number,
659 col: number,
660 options: LspOptions = {},
661): Promise<{ places: PlaceLine[]; notes: string[] }> => {
662 const { answer, notes } = await ask(run, 'references', file, { repo, file, line, col }, options)
663 const places: PlaceLine[] = []
664
665 for (const item of listOf(answer.places)) {
666 const place = toPlace(item)
667
668 if (place !== undefined) {
669 places.push({ ...place, text: textOf(fields(item)?.text) })
670 }
671 }
672
673 return { places, notes }
674}
675
676// Who calls the function at a place ('incoming') or what it calls
677// ('outgoing'), one level. A node's place is where that caller or callee is
678// declared (its name), not the line of the call. At most 500.
679export const lspCalls = async (
680 run: Run,
681 repo: string,
682 file: string,
683 line: number,
684 col: number,
685 direction: 'incoming' | 'outgoing',
686 options: LspOptions = {},
687): Promise<{ calls: CallNode[]; notes: string[] }> => {
688 const request = { repo, file, line, col, direction }
689 const { answer, notes } = await ask(run, 'calls', file, request, options)
690 const calls: CallNode[] = []
691
692 for (const item of listOf(answer.calls)) {
693 const node = fields(item)
694 const place = toPlace(node?.place)
695
696 if (node !== undefined && place !== undefined) {
697 calls.push({
698 name: textOf(node.name),
699 kind: textOf(node.kind),
700 place,
701 detail: textOf(node.detail),
702 })
703 }
704 }
705
706 return { calls, notes }
707}
708
709// The file's functions, classes, methods, variables and so on, in the file's
710// order, a parent before its children. `line`..`endLine` is all an entry
711// spans (a decorator above it included); `col` is its name's column when the
712// name is on `line`, else where the span starts. At most 5,000.
713export const lspOutline = async (
714 run: Run,
715 repo: string,
716 file: string,
717 options: LspOptions = {},
718): Promise<{ items: OutlineItem[]; notes: string[] }> => {
719 const { answer, notes } = await ask(run, 'outline', file, { repo, file }, options)
720 const items: OutlineItem[] = []
721
722 for (const item of listOf(answer.items)) {
723 const entry = fields(item)
724
725 if (
726 entry !== undefined &&
727 isPlace(entry.line) &&
728 isPlace(entry.endLine) &&
729 isPlace(entry.col) &&
730 isCount(entry.depth)
731 ) {
732 items.push({
733 name: textOf(entry.name),
734 kind: textOf(entry.kind),
735 line: entry.line,
736 endLine: entry.endLine,
737 col: entry.col,
738 depth: entry.depth,
739 })
740 }
741 }
742
743 return { items, notes }
744}
745
746// What every name in the file is, in the file's order. The bridge sends each
747// token as [line, col, length, type, modifier bits] against a legend, which
748// keeps a large file's answer small; at most 100,000 tokens (a note says so).
749export const lspSemanticTokens = async (
750 run: Run,
751 repo: string,
752 file: string,
753 options: LspOptions = {},
754): Promise<{ tokens: SemanticToken[]; notes: string[] }> => {
755 const { answer, notes } = await ask(run, 'tokens', file, { repo, file }, options)
756 const types = listOf(answer.types).map(textOf)
757 const names = listOf(answer.modifiers).map(textOf)
758 const tokens: SemanticToken[] = []
759
760 for (const row of listOf(answer.rows)) {
761 const [line, col, length, type, bits] = listOf(row)
762 const name = isCount(type) ? types[type] : undefined
763
764 if (isPlace(line) && isPlace(col) && isCount(length) && isCount(bits) && name) {
765 tokens.push({
766 line,
767 col,
768 length,
769 type: name,
770 modifiers: names.filter((modifier, index) => modifier !== '' && (bits & (1 << index)) !== 0),
771 })
772 }
773 }
774
775 return { tokens, notes }
776}
777
778// Symbols anywhere in the project whose name matches `query`, the closest
779// matches and the project's own files first. `near` is a file of the project
780// (relative to `repo`): it picks the server and the project. At most
781// `options.limit` hits (50 when not given, never more than 500).
782export const lspWorkspaceSymbols = async (
783 run: Run,
784 repo: string,
785 query: string,
786 near: string,
787 options: LspOptions & { limit?: number } = {},
788): Promise<{ hits: SymbolHit[]; notes: string[] }> => {
789 const request = { repo, near, query, limit: options.limit ?? 50 }
790 const { answer, notes } = await ask(run, 'symbols', near, request, options)
791 const hits: SymbolHit[] = []
792
793 for (const item of listOf(answer.hits)) {
794 const hit = fields(item)
795 const place = toPlace(hit?.place)
796
797 if (hit !== undefined && place !== undefined) {
798 hits.push({
799 name: textOf(hit.name),
800 kind: textOf(hit.kind),
801 container: textOf(hit.container),
802 place,
803 })
804 }
805 }
806
807 return { hits, notes }
808}
809
810// The inferred types and parameter names the server would draw inside lines
811// `fromLine`..`toLine` (1-based, both included), in the file's order. A label
812// carries the space it is drawn with ('amount: ' before an argument, ': int'
813// after a name). At most 5,000.
814export const lspInlayHints = async (
815 run: Run,
816 repo: string,
817 file: string,
818 fromLine: number,
819 toLine: number,
820 options: LspOptions = {},
821): Promise<{ hints: InlayHint[]; notes: string[] }> => {
822 const request = { repo, file, fromLine, toLine }
823 const { answer, notes } = await ask(run, 'hints', file, request, options)
824 const hints: InlayHint[] = []
825
826 for (const item of listOf(answer.hints)) {
827 const hint = fields(item)
828
829 if (hint !== undefined && isPlace(hint.line) && isPlace(hint.col)) {
830 hints.push({
831 line: hint.line,
832 col: hint.col,
833 label: textOf(hint.label),
834 kind: hint.kind === 'type' || hint.kind === 'parameter' ? hint.kind : 'other',
835 })
836 }
837 }
838
839 return { hints, notes }
840}
841hooks/lsp-types.ts 61 lines1// What the language-server bridge answers with, beyond diagnostics: the shapes
2// the bridge (lsp.ts) produces and the pane's helpers (semantic.ts) and screens
3// consume. Lines and columns are 1-based throughout; a column is a JS string
4// index + 1 on the file's own text (a tab is one character).
5
6// A position in a file. `path` is relative to the folder under review when the
7// file is inside it (`isInRepo`), else absolute.
8export type Place = { path: string; line: number; col: number; isInRepo: boolean }
9
10// A place with the text of its line, trimmed, for a list a person reads.
11export type PlaceLine = Place & { text: string }
12
13// One entry of a file's outline: a function, class, method, variable and so
14// on. `kind` is the LSP SymbolKind's name in lower case ('function', 'class',
15// 'method', 'property', 'variable', 'constant', 'interface', 'enum', ...).
16// `line`..`endLine` is everything it spans (its fold); `depth` is how deeply
17// it is nested, 0 at the top. Entries come in the file's order, a parent
18// before its children.
19export type OutlineItem = {
20 name: string
21 kind: string
22 line: number
23 endLine: number
24 col: number
25 depth: number
26}
27
28// What a name is, as the server understands it: `type` and `modifiers` are the
29// LSP semantic token names ('parameter', 'property', 'variable', 'function',
30// 'method', 'class', 'type', 'namespace', 'enumMember', 'typeParameter', ...;
31// 'readonly', 'declaration', 'defaultLibrary', 'async', ...). A token never
32// spans lines.
33export type SemanticToken = {
34 line: number
35 col: number
36 length: number
37 type: string
38 modifiers: string[]
39}
40
41// A hint the server would draw inside the code: an inferred type after a name
42// (': int'), or a parameter's name before an argument ('amount='). It sits
43// before the character at `col`.
44export type InlayHint = {
45 line: number
46 col: number
47 label: string
48 kind: 'type' | 'parameter' | 'other'
49}
50
51// One caller or callee of a function: its name and kind, where it is, and what
52// the server says of it besides (its container or signature).
53export type CallNode = { name: string; kind: string; place: Place; detail: string }
54
55// A match of a project-wide search for a name.
56export type SymbolHit = { name: string; kind: string; container: string; place: Place }
57
58// The call the cursor is inside: its whole label, each parameter's own text,
59// which one is active (0-based, -1 for none), and its docs.
60export type Signature = { label: string; parameters: string[]; active: number; docs: string }
61hooks/prompt.ts 89 lines1// What the pane hands the prompt, as text: a list of issues, a block of
2// code with its place, a line's diagnostics with the line.
3
4import type { Diag } from '../types'
5
6// How many issues, or places, one press sends to the prompt.
7export const ISSUES_SENT = 40
8
9// Diagnostics as one list for the prompt, a line each, the worst first.
10export const issueList = (diags: readonly Diag[], limit: number): string => {
11 const ranked = [...diags].sort(
12 (a, b) =>
13 Number(b.severity === 'error') - Number(a.severity === 'error') ||
14 a.path.localeCompare(b.path) ||
15 a.line - b.line,
16 )
17 const lines = ranked
18 .slice(0, limit)
19 .map(
20 diag =>
21 `- ${diag.path}:${diag.line}:${diag.col} ${diag.severity} ${diag.tool}${diag.rule === '' ? '' : ` ${diag.rule}`}: ${diag.message.replace(/\s+/g, ' ')}`,
22 )
23
24 return [...lines, ...(ranked.length > limit ? [`- … and ${ranked.length - limit} more`] : [])].join(
25 '\n',
26 )
27}
28
29const FENCES: [pattern: RegExp, language: string][] = [
30 [/\.pyi?$/, 'python'],
31 [/\.[cm]?tsx$/, 'tsx'],
32 [/\.[cm]?ts$/, 'ts'],
33 [/\.[cm]?jsx$/, 'jsx'],
34 [/\.[cm]?js$/, 'js'],
35]
36
37const fence = (path: string, body: string): string =>
38 `\`\`\`${FENCES.find(([pattern]) => pattern.test(path))?.[1] ?? ''}\n${body}\n\`\`\``
39
40// Lines `from` to `to` of a file as the prompt takes them: where they are,
41// then the code.
42export const codeBlock = (
43 path: string,
44 from: number,
45 to: number,
46 texts: readonly string[],
47): string =>
48 `${path}:${from === to ? from : `${from}-${to}`}\n${fence(path, texts.slice(from - 1, to).join('\n'))}`
49
50export const quoteBlock = (path: string, text: string): string =>
51 `From ${path}:\n${fence(path, text.replace(/\s+$/, ''))}`
52
53// A review thread as the prompt takes it: who said what on which line (the
54// replies under the comment they answer), then the code it is about, lines
55// `from` to `to`. `request` names the pull or merge request it is on ("PR #12",
56// "MR !34"), '' when that is not known.
57export const talkBlock = (
58 request: string,
59 path: string,
60 line: number,
61 thread: readonly { author: string; body: string; isResolved?: boolean }[],
62 from: number,
63 to: number,
64 texts: readonly string[],
65): string =>
66 [
67 `Review comment${request === '' ? '' : ` in ${request}`} on ${path}:${line}${thread.some(one => one.isResolved === true) ? ' (resolved)' : ''}:`,
68 ...thread.map(
69 (one, at) => `${at === 0 ? '-' : ' -'} ${one.author}: ${one.body.trim().replace(/\s*\n\s*/g, ' ')}`,
70 ),
71 codeBlock(path, from, to, texts),
72 ].join('\n')
73
74// A line's diagnostics as the prompt takes them: each message with its tool
75// and rule, then the line they point at.
76export const diagBlock = (
77 path: string,
78 line: number,
79 diags: readonly Diag[],
80 texts: readonly string[],
81): string =>
82 [
83 ...diags.map(
84 diag =>
85 `${path}:${line}:${diag.col} ${diag.isNew === undefined ? '' : diag.isNew ? 'new ' : 'pre-existing '}${diag.severity} ${diag.tool}${diag.rule === '' ? '' : ` ${diag.rule}`}: ${diag.message.replace(/\s+/g, ' ')}`,
86 ),
87 fence(path, texts[line - 1] ?? ''),
88 ].join('\n')
89hooks/recents.ts 126 lines1// What the mod keeps between sessions, and what it leaves on the machine.
2//
3// Kept: the repos reviewed lately, each with the comparison and layout it
4// was left in, so /lens picks one up where it was and has a list to offer
5// when it is run outside any repo. Left behind by a session: the exports of
6// commits the checkers ran over, the refs a pull request was fetched under,
7// and the language servers' keeper; `cleanUp` removes all three.
8
9import type { Recent, View } from '../types'
10import type { Run } from './run'
11
12// How many repos the list holds.
13export const RECENTS = 12
14
15// A review as the list keeps it.
16export const recentOf = (now: View, at: number, home = ''): Recent => ({
17 repo: now.repo,
18 // A comparison with another worktree is with a snapshot that does not last.
19 base: (now.baseWorktree ?? '') === '' ? now.base : 'HEAD',
20 target: now.target ?? '',
21 request: (now.target ?? '') === '' ? '' : (now.request ?? ''),
22 requestTyped: (now.target ?? '') === '' ? '' : (now.requestTyped ?? ''),
23 layout: now.layout ?? 'tree',
24 isBrowsing: now.isBrowsing ?? false,
25 at,
26 home,
27})
28
29// The list with a review put first, in place of what it held of that repo.
30export const remember = (list: readonly Recent[], one: Recent): Recent[] =>
31 [one, ...list.filter(other => other.repo !== one.repo)].slice(0, RECENTS)
32
33// The list as the store gave it back: whatever is not a review is dropped,
34// so a store written by another version, or by hand, cannot break a reader.
35export const settledRecents = (stored: unknown): Recent[] =>
36 (Array.isArray(stored) ? stored : []).flatMap((one: unknown): Recent[] => {
37 if (typeof one !== 'object' || one === null) {
38 return []
39 }
40
41 const held = one as Partial<Record<keyof Recent, unknown>>
42 const text = (value: unknown, fallback = ''): string =>
43 typeof value === 'string' ? value : fallback
44
45 return typeof held.repo === 'string' && held.repo.startsWith('/')
46 ? [
47 {
48 repo: held.repo,
49 base: text(held.base, 'HEAD') || 'HEAD',
50 target: text(held.target),
51 request: text(held.request),
52 requestTyped: text(held.requestTyped),
53 layout: held.layout === 'list' ? 'list' : 'tree',
54 isBrowsing: held.isBrowsing === true,
55 at: typeof held.at === 'number' ? held.at : 0,
56 home: text(held.home),
57 },
58 ]
59 : []
60 })
61
62// The list as the recents screen shows it: the worktrees of one repo
63// together, under the repo they belong to, each group where its latest
64// review sits in the list.
65export const groupRecents = (list: readonly Recent[]): { home: string; reviews: Recent[] }[] => {
66 const groups = new Map<string, Recent[]>()
67
68 for (const one of list) {
69 const home = one.home === '' ? one.repo : one.home
70
71 groups.set(home, [...(groups.get(home) ?? []), one])
72 }
73
74 return [...groups].map(([home, reviews]) => ({ home, reviews }))
75}
76
77// What a review was comparing, in a few words, for the list.
78export const comparisonLabel = (one: Recent): string =>
79 one.request !== ''
80 ? one.request
81 : one.target !== ''
82 ? `${one.target} vs ${one.base}`
83 : one.base === 'HEAD'
84 ? 'uncommitted changes'
85 : `working tree vs ${one.base}`
86
87// How long ago, in a word or two.
88export const agoOf = (at: number, now: number): string => {
89 const minutes = Math.max(0, Math.round((now - at) / 60_000))
90
91 return at === 0
92 ? ''
93 : minutes < 1
94 ? 'just now'
95 : minutes < 60
96 ? `${minutes} min ago`
97 : minutes < 60 * 24
98 ? `${Math.round(minutes / 60)} h ago`
99 : `${Math.round(minutes / (60 * 24))} d ago`
100}
101
102// Removes what sessions leave on the machine. It is started and let go of,
103// so a session's end does not wait for it: the servers' keeper is asked to
104// stop, the exports of commits are deleted, and in each repo given the refs
105// pull requests were fetched under are deleted (nothing else of a repo is
106// touched). A review picked up again exports and fetches what it needs.
107const CLEAN_UP = [
108 '(',
109 ' d="/tmp/lens-lsp-$(id -u)"',
110 ' if [ -S "$d/bridge.sock" ] && cd "$d"; then',
111 ' if command -v uv >/dev/null 2>&1; then echo "{}" | uv run --no-project python bridge.py stop',
112 ' else echo "{}" | python3 bridge.py stop; fi',
113 ' fi',
114 ' rm -rf "${TMPDIR:-/tmp}/lens-base"',
115 ' for repo in "$@"; do',
116 ' git -C "$repo" for-each-ref --format="delete %(refname)" refs/lens |',
117 ' git -C "$repo" update-ref --stdin',
118 ' done',
119 ') >/dev/null 2>&1 </dev/null &',
120].join('\n')
121
122export const cleanUp = (run: Run, repos: readonly string[]): Promise<unknown> =>
123 run(['sh', '-c', CLEAN_UP, 'sh', ...new Set(repos.filter(repo => repo.startsWith('/')))], {
124 timeoutMs: 1000,
125 })
126hooks/ledger.ts 77 lines1// The ledger mod's review findings as the pane's review comments, so each
2// shows on its line the way a request's thread does. Lens works without the
3// ledger: with no run there are none.
4
5import type { LedgerRunSeen } from '../types/ledger'
6import type { Comment } from './review'
7
8const PREFIX = 'ledger-'
9
10// Whether a comment is a ledger finding: it has no thread on a forge to answer
11// or resolve.
12export const isFinding = (comment: Comment): boolean => comment.id.startsWith(PREFIX)
13
14// A path with its `.` and `..` parts folded.
15const folded = (path: string): string => {
16 const parts: string[] = []
17
18 for (const part of path.split('/')) {
19 if (part === '..') {
20 parts.pop()
21 } else if (part !== '.' && part !== '') {
22 parts.push(part)
23 }
24 }
25
26 return `${path.startsWith('/') ? '/' : ''}${parts.join('/')}`
27}
28
29// A path another mod names, made absolute: as it is when it starts at the
30// root, else from `root`, the session's folder.
31export const placeOf = (path: string, root: string): string =>
32 folded(path.startsWith('/') ? path : `${root}/${path}`)
33
34// Where a finding's file is under the folder being reviewed, or undefined when
35// it is not there. An agent writes the path from the session's folder, which
36// may be the folder under review, one above it, or one inside it; failing
37// those, a changed file whose path ends with the finding's is the one meant.
38const placed = (path: string, repo: string, root: string, files: readonly string[]): string | undefined => {
39 const full = folded(path.startsWith('/') ? path : `${root}/${path}`)
40
41 if (full.startsWith(`${repo}/`)) {
42 return full.slice(repo.length + 1)
43 }
44
45 const tail = folded(path).replace(/^\//, '')
46
47 return files.find(one => one === tail || one.endsWith(`/${tail}`) || tail.endsWith(`/${one}`))
48}
49
50// The run's findings as comments on the files of `repo`, the folder under
51// review. `root` is the session's folder and `files` the changed files' paths.
52// A fixed finding is a resolved thread; one with no line is on the file as a
53// whole.
54export const findingComments = (
55 run: LedgerRunSeen | null | undefined,
56 repo: string,
57 root: string,
58 files: readonly string[],
59): Comment[] =>
60 (run?.findings ?? []).flatMap(one => {
61 const path = placed(one.path, repo, root, files)
62
63 return path === undefined
64 ? []
65 : [
66 {
67 id: `${PREFIX}${one.id}`,
68 path,
69 line: one.line ?? 0,
70 author: `ledger ${one.severity}${one.task === undefined ? '' : ` · ${one.task}`}`,
71 body: one.summary,
72 when: new Date(one.at).toISOString(),
73 ...(one.status === 'fixed' ? { isResolved: true } : {}),
74 },
75 ]
76 })
77hooks/review.ts 1199 lines1// Pull request / merge request lookup: turns what the user typed into two refs that exist locally.
2// Handle-free on purpose: every command goes through the `run` the caller passes in.
3
4export type Run = (
5 argv: string[],
6 timeoutMs?: number,
7) => Promise<{ exitCode: number; stdout: string; stderr: string }>
8
9export type Forge = 'github' | 'gitlab' | 'unknown'
10
11export type Request = {
12 host: Forge
13 number: number
14 // "owner/repo" (or "group/sub/repo") when the text was a URL, so a link to another repo can be refused.
15 repo?: string
16}
17
18export type Resolved = {
19 side: string // the private ref holding the request's head, e.g. refs/lens/pr-12
20 against: string // commit hash the head is compared with: where the branch forked from its target
21 title: string // the request's title, or a note that the target was guessed
22 label: string // "PR #12" or "MR !34"
23 target: string // the target branch name, e.g. "main"
24 isTargetGuessed: boolean // true when the forge CLI could not be asked
25}
26
27const FETCH_MS = 120_000
28const CLI_MS = 30_000
29const LOCAL_MS = 10_000
30
31const REF_ROOT = 'refs/lens'
32
33const asNumber = (digits: string | undefined): number | undefined => {
34 const value = Number(digits)
35
36 return Number.isSafeInteger(value) && value > 0 ? value : undefined
37}
38
39const trimRepo = (path: string): string => path.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '')
40
41export const parseRequest = (text: string): Request | undefined => {
42 const typed = text.trim()
43
44 const mergeRequest = /^(?:https?:\/\/)?[^/\s]+\/(\S+?)\/(?:-\/)?merge_requests\/(\d+)(?:[/?#]\S*)?$/i.exec(typed)
45 const pull = /^(?:https?:\/\/)?[^/\s]+\/(\S+?)\/pull\/(\d+)(?:[/?#]\S*)?$/i.exec(typed)
46 const linked = mergeRequest ?? pull
47
48 if (linked) {
49 const number = asNumber(linked[2])
50
51 return number === undefined
52 ? undefined
53 : { host: mergeRequest ? 'gitlab' : 'github', number, repo: trimRepo(linked[1] ?? '') }
54 }
55
56 const short = /^(pr|pull|mr)?\s*([#!])?\s*(\d+)$/i.exec(typed)
57
58 if (!short) {
59 return undefined
60 }
61
62 const number = asNumber(short[3])
63 const word = short[1]?.toLowerCase()
64
65 if (number === undefined) {
66 return undefined
67 }
68
69 if (word !== undefined) {
70 return { host: word === 'mr' ? 'gitlab' : 'github', number }
71 }
72
73 // "!34" is GitLab's own notation. "#12" is used loosely for both, so it is not a hint.
74 return { host: short[2] === '!' ? 'gitlab' : 'unknown', number }
75}
76
77// Host and "owner/repo" path of a git remote URL, in its https, ssh:// or scp-like (git@host:path) form.
78export const remoteParts = (remoteUrl: string): { host: string; repo: string } | undefined => {
79 const url = remoteUrl.trim()
80 const withScheme = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s]+@)?([^/:\s]+)(?::\d+)?\/(\S+)$/i.exec(url)
81 const scpLike = /^(?:[^@/\s]+@)?([^/:\s]+):(?!\/\/)(\S+)$/.exec(url)
82 const found = withScheme ?? scpLike
83
84 if (!found) {
85 return undefined
86 }
87
88 return { host: (found[1] ?? '').toLowerCase(), repo: trimRepo(found[2] ?? '') }
89}
90
91export const forgeOf = (remoteUrl: string): Forge => {
92 const host = remoteParts(remoteUrl)?.host ?? ''
93
94 if (host.includes('github')) {
95 return 'github'
96 }
97
98 return host.includes('gitlab') ? 'gitlab' : 'unknown'
99}
100
101const lastLine = (text: string): string =>
102 text
103 .split('\n')
104 .map(line => line.trim())
105 .filter(line => line !== '')
106 .at(-1) ?? ''
107
108const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
109
110type Viewed = { title: string; target: string; forkPoint: string } | { why: string }
111
112// Why the forge CLI gave nothing, in words that fit after "target guessed as main: ".
113const whyNot = (cli: string, ran: { exitCode: number; stderr: string }): string => {
114 if (/timed? ?out/i.test(ran.stderr)) {
115 return `${cli} did not answer`
116 }
117
118 if (
119 ran.exitCode === -1 ||
120 ran.exitCode === 127 ||
121 /ENOENT|command not found|no such file|not found in \$?PATH/i.test(ran.stderr)
122 ) {
123 return `${cli} is not installed`
124 }
125
126 if (/auth login|logged in|authenticat|unauthorized|401|token/i.test(ran.stderr)) {
127 return `${cli} is not signed in, run ${cli} auth login`
128 }
129
130 return `${cli} could not read it`
131}
132
133const view = async (run: Run, forge: 'github' | 'gitlab', number: number, slug: string | undefined): Promise<Viewed> => {
134 const cli = forge === 'github' ? 'gh' : 'glab'
135
136 // gh is pointed at origin explicitly: on its own it may pick another remote (a fork's upstream).
137 const argv =
138 forge === 'github'
139 ? [
140 'gh',
141 'pr',
142 'view',
143 String(number),
144 ...(slug === undefined ? [] : ['--repo', slug]),
145 '--json',
146 'number,title,baseRefName,headRefName,baseRefOid',
147 ]
148 : ['glab', 'mr', 'view', String(number), '--output', 'json']
149
150 const ran = await run(argv, CLI_MS).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
151
152 if (ran.exitCode !== 0) {
153 return { why: whyNot(cli, ran) }
154 }
155
156 try {
157 const parsed = JSON.parse(ran.stdout) as Record<string, unknown>
158 const refs = (parsed.diff_refs ?? {}) as Record<string, unknown>
159 const target = text(forge === 'github' ? parsed.baseRefName : parsed.target_branch)
160
161 if (target === '') {
162 return { why: `${cli} did not name a target branch` }
163 }
164
165 return {
166 title: text(parsed.title),
167 target,
168 forkPoint: text(forge === 'github' ? parsed.baseRefOid : refs.base_sha),
169 }
170 } catch {
171 return { why: `${cli} gave an answer that could not be read` }
172 }
173}
174
175export const resolveRequest = async (run: Run, typed: string): Promise<Resolved | { error: string }> => {
176 const request = parseRequest(typed)
177
178 if (!request) {
179 return { error: `"${typed.trim()}" is not a PR or MR: type a number like 12, or paste its URL` }
180 }
181
182 const remote = await run(['git', 'remote', 'get-url', 'origin'], LOCAL_MS)
183
184 if (remote.exitCode !== 0 || remote.stdout.trim() === '') {
185 return { error: 'This repo has no remote named origin to fetch a PR or MR from' }
186 }
187
188 const origin = remoteParts(remote.stdout)
189 const known = forgeOf(remote.stdout)
190 const forge = known === 'unknown' ? request.host : known
191
192 if (forge === 'unknown') {
193 return {
194 error: `This repo's origin is not GitHub or GitLab; type "pr ${request.number}" or "mr ${request.number}" to say which it is`,
195 }
196 }
197
198 const label = forge === 'github' ? `PR #${request.number}` : `MR !${request.number}`
199
200 if (request.repo !== undefined && origin && request.repo.toLowerCase() !== origin.repo.toLowerCase()) {
201 return { error: `That link is for ${request.repo}, but this repo's origin is ${origin.repo}; open that repo instead` }
202 }
203
204 const side = `${REF_ROOT}/${forge === 'github' ? 'pr' : 'mr'}-${request.number}`
205 const tip = `${side}-base`
206 const remoteHead = `refs/${forge === 'github' ? 'pull' : 'merge-requests'}/${request.number}/head`
207
208 // The leading + lets a later fetch move the private ref after a force-push. No local branch is touched.
209 const [fetched, viewed] = await Promise.all([
210 run(['git', 'fetch', '--no-tags', 'origin', `+${remoteHead}:${side}`], FETCH_MS),
211 view(run, forge, request.number, origin ? `${origin.host}/${origin.repo}` : undefined),
212 ])
213
214 if (fetched.exitCode !== 0) {
215 return {
216 error: /couldn't find remote ref/i.test(fetched.stderr)
217 ? `${label} was not found on origin`
218 : `${label} could not be fetched from origin: ${lastLine(fetched.stderr) || 'git fetch failed'}`,
219 }
220 }
221
222 const fetchTarget = async (branch: string) =>
223 run(['git', 'fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${tip}`], FETCH_MS)
224
225 let target = ''
226
227 if ('why' in viewed) {
228 const pointed = await run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], LOCAL_MS)
229 const named = pointed.exitCode === 0 ? pointed.stdout.trim().replace(/^origin\//, '') : ''
230
231 for (const branch of [...new Set([named, 'main', 'master'])].filter(one => one !== '')) {
232 if ((await fetchTarget(branch)).exitCode === 0) {
233 target = branch
234 break
235 }
236 }
237
238 if (target === '') {
239 return { error: `No target branch for ${label} could be found on origin (${viewed.why})` }
240 }
241 } else {
242 const got = await fetchTarget(viewed.target)
243
244 if (got.exitCode !== 0) {
245 return {
246 error: `The target branch ${viewed.target} of ${label} could not be fetched from origin: ${lastLine(got.stderr) || 'git fetch failed'}`,
247 }
248 }
249
250 target = viewed.target
251 }
252
253 // The forge records the target's commit the request is measured from. Once a request is merged, its
254 // head is inside the target, so the target's tip alone would give an empty comparison.
255 const forkPoint = 'why' in viewed ? '' : viewed.forkPoint
256 const hasForkPoint =
257 forkPoint !== '' && (await run(['git', 'cat-file', '-e', `${forkPoint}^{commit}`], LOCAL_MS)).exitCode === 0
258
259 const merged = await run(['git', 'merge-base', side, hasForkPoint ? forkPoint : tip], LOCAL_MS)
260 const against = merged.stdout.trim()
261
262 if (merged.exitCode !== 0 || against === '') {
263 return {
264 error: `${label} and ${target} share no history here; if this is a shallow clone, run git fetch --unshallow`,
265 }
266 }
267
268 const head = await run(['git', 'rev-parse', side], LOCAL_MS)
269 const isEmpty = head.stdout.trim() === against
270
271 const notes = [
272 ...('why' in viewed ? [`target guessed as ${target}: ${viewed.why}`] : []),
273 ...(isEmpty ? [`already merged into ${target}, nothing left to compare`] : []),
274 ]
275
276 const name = 'why' in viewed ? '' : viewed.title
277
278 return {
279 side,
280 against,
281 title: [name, notes.length > 0 ? `(${notes.join('; ')})` : ''].filter(part => part !== '').join(' ') || label,
282 label,
283 target,
284 isTargetGuessed: 'why' in viewed,
285 }
286}
287
288// ---------------------------------------------------------------------------------------------
289// Review comments: reading every comment on a request, and posting one on a line.
290// ---------------------------------------------------------------------------------------------
291
292export type Comment = {
293 // Line comments carry the forge's own number ("4109147516"). On GitHub the general ones are
294 // prefixed ("issue-5917169258", "review-5393240365"), because the three kinds are numbered apart.
295 id: string
296 // Where it is attached: a path relative to the REPO ROOT and a 1-based line in the request's
297 // head version of the file. `line` is 0 for a comment on the file as a whole, one on a removed
298 // line (see oldLine), or one whose line no longer exists (outdated). `path` is '' for a general
299 // comment on the request.
300 path: string
301 line: number
302 author: string
303 body: string
304 when: string // ISO time as the forge gives it
305 replyTo?: string // the id of the comment it answers: always the first comment of its thread
306 isResolved?: boolean // where the forge says
307 isOutdated?: boolean // attached to a version of the file that has since changed
308 oldLine?: number // for a comment on a removed line: its line in the target's version of the file
309 // What the forge calls the thread it is in, by which the thread is resolved (and, on GitLab,
310 // replied to): GitHub's node id of the review thread, GitLab's discussion id. Absent where the
311 // forge did not say, and on general comments.
312 thread?: string
313}
314
315type Json = Record<string, unknown>
316
317type Place = {
318 forge: 'github' | 'gitlab'
319 cli: 'gh' | 'glab'
320 number: number
321 host: string
322 repo: string // "owner/repo", or "group/sub/repo"
323 label: string // "PR #12" or "MR !34"
324 noun: string // "pull request" or "merge request"
325}
326
327const COMMENTS_MS = 60_000
328
329const record = (value: unknown): Json => (typeof value === 'object' && value !== null ? (value as Json) : {})
330
331const whole = (value: unknown): number => (typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : 0)
332
333const named = (value: unknown): string => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
334
335// Every JSON value in a CLI's output, with arrays flattened: a paginated call may print one merged
336// array, or one value per page back to back.
337const values = (stdout: string): unknown[] => {
338 const found: unknown[] = []
339 const take = (slice: string) => {
340 const parsed: unknown = JSON.parse(slice)
341
342 found.push(...(Array.isArray(parsed) ? (parsed as unknown[]) : [parsed]))
343 }
344
345 try {
346 take(stdout)
347
348 return found
349 } catch {
350 found.length = 0
351 }
352
353 let depth = 0
354 let start = -1
355 let isInString = false
356 let isEscaped = false
357
358 for (let at = 0; at < stdout.length; at++) {
359 const char = stdout[at]
360
361 if (isInString) {
362 if (isEscaped) {
363 isEscaped = false
364 } else if (char === '\\') {
365 isEscaped = true
366 } else if (char === '"') {
367 isInString = false
368 }
369 } else if (char === '"') {
370 isInString = true
371 } else if (char === '{' || char === '[') {
372 if (depth === 0) {
373 start = at
374 }
375
376 depth++
377 } else if (char === '}' || char === ']') {
378 depth--
379
380 if (depth === 0 && start >= 0) {
381 take(stdout.slice(start, at + 1))
382 start = -1
383 }
384 }
385 }
386
387 if (depth !== 0) {
388 throw new Error('cut short')
389 }
390
391 return found
392}
393
394const call = async (run: Run, argv: string[], timeoutMs = COMMENTS_MS) =>
395 run(argv, timeoutMs).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
396
397// What the forge itself said went wrong: the "message" of its JSON answer, else the CLI's last line.
398const forgeSaid = (ran: { stdout: string; stderr: string }): string => {
399 try {
400 const answer = record(JSON.parse(ran.stdout))
401 const said = answer.message ?? answer.error
402
403 return [
404 typeof said === 'string' ? said : said === undefined ? '' : JSON.stringify(said),
405 answer.errors === undefined ? '' : JSON.stringify(answer.errors),
406 ].join(' ')
407 } catch {
408 return lastLine(ran.stderr)
409 }
410}
411
412// One sentence for a forge call that failed. `doing` is "read" or "comment on".
413const whyFailed = (
414 place: Place,
415 ran: { exitCode: number; stdout: string; stderr: string },
416 doing: 'read' | 'comment on',
417 at?: { path: string; line: number },
418): string => {
419 const { cli, label, noun } = place
420 const all = `${ran.stderr}\n${ran.stdout}`
421 const status = /HTTP (\d{3})/.exec(ran.stderr)?.[1] ?? ''
422
423 if (status === '') {
424 if (/timed? ?out/i.test(ran.stderr)) {
425 return `${cli} did not answer`
426 }
427
428 if (
429 ran.exitCode === -1 ||
430 ran.exitCode === 127 ||
431 /ENOENT|command not found|no such file|not found in \$?PATH/i.test(ran.stderr)
432 ) {
433 return `${cli} is not installed`
434 }
435 }
436
437 if (status === '401' || (status === '' && /auth login|not logged in|authenticat|unauthorized|bad credentials/i.test(all))) {
438 return `${cli} is not signed in: run ${cli} auth login`
439 }
440
441 if (/rate limit/i.test(all) || status === '429') {
442 return `${place.forge === 'github' ? 'GitHub' : 'GitLab'} is refusing more calls for now (rate limit): try again in a few minutes`
443 }
444
445 if (status === '403') {
446 return `You do not have permission to ${doing} this ${noun}`
447 }
448
449 if (status === '404' || /could not resolve to a/i.test(all)) {
450 return `${label} was not found in ${place.repo}, or you may not see it`
451 }
452
453 if (at && (status === '422' || status === '400')) {
454 if (/commit_id|head_sha|base_sha|start_sha/i.test(all)) {
455 return `This ${noun} has changed since it was opened here: reopen it, then comment again`
456 }
457
458 if (/line|diff_hunk|position|path/i.test(all)) {
459 return `Line ${at.line} of ${at.path} is not part of this ${noun}'s diff`
460 }
461 }
462
463 const said = forgeSaid(ran).replace(/\s+/g, ' ').trim().slice(0, 160)
464
465 return doing === 'read'
466 ? `The comments of ${label} could not be read: ${said || `${cli} failed`}`
467 : `The comment could not be posted on ${label}: ${said || `${cli} failed`}`
468}
469
470// Which request `typed` names, on which forge and repo: the same reading of it as resolveRequest.
471const locate = async (run: Run, typed: string): Promise<Place | { error: string }> => {
472 const request = parseRequest(typed)
473
474 if (!request) {
475 return { error: `"${typed.trim()}" is not a PR or MR: type a number like 12, or paste its URL` }
476 }
477
478 const remote = await call(run, ['git', 'remote', 'get-url', 'origin'], LOCAL_MS)
479 const origin = remote.exitCode === 0 ? remoteParts(remote.stdout) : undefined
480
481 if (!origin || origin.repo === '') {
482 return { error: 'This repo has no remote named origin to read a PR or MR from' }
483 }
484
485 const known = forgeOf(remote.stdout)
486 const forge = known === 'unknown' ? request.host : known
487
488 if (forge === 'unknown') {
489 return {
490 error: `This repo's origin is not GitHub or GitLab; type "pr ${request.number}" or "mr ${request.number}" to say which it is`,
491 }
492 }
493
494 if (request.repo !== undefined && request.repo.toLowerCase() !== origin.repo.toLowerCase()) {
495 return { error: `That link is for ${request.repo}, but this repo's origin is ${origin.repo}; open that repo instead` }
496 }
497
498 return {
499 forge,
500 cli: forge === 'github' ? 'gh' : 'glab',
501 number: request.number,
502 host: origin.host,
503 repo: origin.repo,
504 label: forge === 'github' ? `PR #${request.number}` : `MR !${request.number}`,
505 noun: forge === 'github' ? 'pull request' : 'merge request',
506 }
507}
508
509const byTime = (comments: Comment[]): Comment[] =>
510 comments
511 .map((comment, index) => ({ comment, index }))
512 .sort((one, other) =>
513 one.comment.when === other.comment.when
514 ? one.index - other.index
515 : one.comment.when < other.comment.when
516 ? -1
517 : 1,
518 )
519 .map(({ comment }) => comment)
520
521// ---- GitHub ----
522
523type ThreadState = { isResolved: boolean; isOutdated: boolean; id: string }
524
525const THREADS_QUERY =
526 'query($owner:String!,$name:String!,$number:Int!,$endCursor:String){repository(owner:$owner,name:$name){' +
527 'pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor}' +
528 'nodes{id isResolved isOutdated comments(first:1){nodes{databaseId}}}}}}}'
529
530const gh = (place: Place, ...rest: string[]): string[] => ['gh', 'api', '--hostname', place.host, ...rest]
531
532// One review comment as GitHub's REST API gives it. `threads` is keyed by the id of a thread's first comment.
533const fromGithubLine = (raw: unknown, threads: Map<string, ThreadState>): Comment => {
534 const one = record(raw)
535 const id = named(one.id)
536 const replyTo = named(one.in_reply_to_id)
537 const thread = threads.get(replyTo || id)
538 const isOnFile = one.subject_type === 'file'
539 const line = whole(one.line)
540 // A comment whose line has since changed keeps only its original_line: `line` comes back null.
541 const isOutdated = !isOnFile && line === 0
542 const isOnOld = one.side === 'LEFT'
543
544 return {
545 id,
546 path: text(one.path),
547 line: isOnOld ? 0 : line,
548 author: text(record(one.user).login) || 'ghost',
549 body: typeof one.body === 'string' ? one.body : '',
550 when: text(one.created_at),
551 ...(replyTo === '' ? {} : { replyTo }),
552 ...(thread ? { isResolved: thread.isResolved } : {}),
553 ...(thread && thread.id !== '' ? { thread: thread.id } : {}),
554 isOutdated,
555 ...(isOnOld && line > 0 ? { oldLine: line } : {}),
556 }
557}
558
559const fromGithubGeneral = (raw: unknown, kind: 'issue' | 'review'): Comment => {
560 const one = record(raw)
561
562 return {
563 id: `${kind}-${named(one.id)}`,
564 path: '',
565 line: 0,
566 author: text(record(one.user).login) || 'ghost',
567 body: typeof one.body === 'string' ? one.body : '',
568 when: text(kind === 'issue' ? one.created_at : one.submitted_at),
569 }
570}
571
572const githubComments = async (run: Run, place: Place): Promise<{ comments: Comment[] } | { error: string }> => {
573 const [owner = '', name = ''] = place.repo.split('/')
574 const base = `repos/${place.repo}`
575
576 const [lines, talk, reviews, threadPages] = await Promise.all([
577 call(run, gh(place, `${base}/pulls/${place.number}/comments?per_page=100`, '--paginate')),
578 call(run, gh(place, `${base}/issues/${place.number}/comments?per_page=100`, '--paginate')),
579 call(run, gh(place, `${base}/pulls/${place.number}/reviews?per_page=100`, '--paginate')),
580 call(
581 run,
582 gh(
583 place,
584 'graphql',
585 '--paginate',
586 '-f',
587 `query=${THREADS_QUERY}`,
588 '-f',
589 `owner=${owner}`,
590 '-f',
591 `name=${name}`,
592 '-F',
593 `number=${place.number}`,
594 ),
595 ),
596 ])
597
598 for (const ran of [lines, talk, reviews]) {
599 if (ran.exitCode !== 0) {
600 return { error: whyFailed(place, ran, 'read') }
601 }
602 }
603
604 // Resolved state only exists on threads, which only GraphQL lists. Without it the comments
605 // are still worth showing, so a failure here is not an error: isResolved is just left out.
606 const threads = new Map<string, ThreadState>()
607
608 if (threadPages.exitCode === 0) {
609 try {
610 for (const page of values(threadPages.stdout)) {
611 const listed = record(record(record(record(record(page).data).repository).pullRequest).reviewThreads).nodes
612
613 for (const node of Array.isArray(listed) ? (listed as unknown[]) : []) {
614 const thread = record(node)
615 const first = (record(thread.comments).nodes as unknown[] | undefined)?.[0]
616 const id = named(record(first).databaseId)
617
618 if (id !== '') {
619 threads.set(id, {
620 isResolved: thread.isResolved === true,
621 isOutdated: thread.isOutdated === true,
622 id: named(thread.id),
623 })
624 }
625 }
626 }
627 } catch {
628 threads.clear()
629 }
630 }
631
632 try {
633 return {
634 comments: byTime([
635 ...values(lines.stdout).map(raw => fromGithubLine(raw, threads)),
636 ...values(talk.stdout).map(raw => fromGithubGeneral(raw, 'issue')),
637 // A review is listed even when it only carried line comments: then its own body is empty.
638 ...values(reviews.stdout)
639 .map(raw => fromGithubGeneral(raw, 'review'))
640 .filter(comment => comment.body.trim() !== '' && comment.when !== ''),
641 ]),
642 }
643 } catch {
644 return { error: `The comments of ${place.label} could not be read: gh gave an answer that could not be read` }
645 }
646}
647
648// ---- GitLab ----
649
650const glab = (place: Place, ...rest: string[]): string[] => ['glab', 'api', '--hostname', place.host, ...rest]
651
652// "group/sub/app" goes in as one encoded path segment, which is how GitLab takes a project by its path.
653const gitlabRequest = (place: Place): string =>
654 `projects/${encodeURIComponent(place.repo)}/merge_requests/${place.number}`
655
656// One note of a discussion. `headSha` is the request's current head, '' when it is not known.
657const fromGitlabNote = (raw: unknown, rootId: string, headSha: string, thread = ''): Comment => {
658 const one = record(raw)
659 const id = named(one.id)
660 const hasPlace = typeof one.position === 'object' && one.position !== null
661 const position = record(one.position)
662 const madeOn = text(position.head_sha)
663 // GitLab moves a note's position along with new commits while its line survives, so a position
664 // still naming an older head is one whose line has changed since.
665 const isOutdated = hasPlace && headSha !== '' && madeOn !== '' ? madeOn !== headSha : undefined
666 const isOnFile = position.position_type !== undefined && position.position_type !== 'text'
667 const newLine = whole(position.new_line)
668 const oldLine = whole(position.old_line)
669
670 return {
671 id,
672 path: hasPlace ? text(position.new_path) || text(position.old_path) : '',
673 line: isOutdated === true || isOnFile ? 0 : newLine,
674 author: text(record(one.author).username) || text(record(one.author).name) || 'ghost',
675 body: typeof one.body === 'string' ? one.body : '',
676 when: text(one.created_at),
677 ...(rootId === id ? {} : { replyTo: rootId }),
678 ...(one.resolvable === true ? { isResolved: one.resolved === true } : {}),
679 ...(isOutdated === undefined ? {} : { isOutdated }),
680 ...(hasPlace && !isOnFile && newLine === 0 && oldLine > 0 ? { oldLine } : {}),
681 // Only a thread that can be resolved is kept by its discussion: a general note has none.
682 ...(thread === '' || one.resolvable !== true ? {} : { thread }),
683 }
684}
685
686const fromGitlabDiscussion = (raw: unknown, headSha: string): Comment[] => {
687 const listed = record(raw).notes
688 const notes = (Array.isArray(listed) ? (listed as unknown[]) : []).filter(note => record(note).system !== true)
689 const rootId = named(record(notes[0]).id)
690
691 const thread = named(record(raw).id)
692
693 return notes.map(note => fromGitlabNote(note, rootId, headSha, thread))
694}
695
696const gitlabComments = async (run: Run, place: Place): Promise<{ comments: Comment[] } | { error: string }> => {
697 const [discussions, request] = await Promise.all([
698 call(run, glab(place, `${gitlabRequest(place)}/discussions?per_page=100`, '--paginate')),
699 call(run, glab(place, gitlabRequest(place))),
700 ])
701
702 if (discussions.exitCode !== 0) {
703 return { error: whyFailed(place, discussions, 'read') }
704 }
705
706 let headSha = ''
707
708 try {
709 headSha = request.exitCode === 0 ? text(record(record(JSON.parse(request.stdout)).diff_refs).head_sha) : ''
710 } catch {
711 headSha = ''
712 }
713
714 try {
715 return { comments: byTime(values(discussions.stdout).flatMap(raw => fromGitlabDiscussion(raw, headSha))) }
716 } catch {
717 return { error: `The comments of ${place.label} could not be read: glab gave an answer that could not be read` }
718 }
719}
720
721// Where a line of the head version sits in the base version, read from `git diff -U0` output:
722// `added` when the line is new, else the line it was. GitLab wants both lines for an unchanged one.
723const oldLineOf = (diff: string, line: number): { isAdded: true } | { isAdded: false; oldLine: number } => {
724 let shift = 0
725
726 for (const hunk of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
727 const removed = hunk[2] === undefined ? 1 : Number(hunk[2])
728 const start = Number(hunk[3])
729 const added = hunk[4] === undefined ? 1 : Number(hunk[4])
730
731 if (added > 0 && line >= start && line < start + added) {
732 return { isAdded: true }
733 }
734
735 // With nothing added, `start` is the line the removal follows.
736 if (added > 0 ? start + added - 1 < line : start < line) {
737 shift += added - removed
738 }
739 }
740
741 return { isAdded: false, oldLine: line - shift }
742}
743
744const gitlabPost = async (
745 run: Run,
746 place: Place,
747 at: { path: string; line: number; commit: string },
748 body: string,
749): Promise<{ comment: Comment } | { error: string }> => {
750 const request = await call(run, glab(place, gitlabRequest(place)))
751
752 if (request.exitCode !== 0) {
753 return { error: whyFailed(place, request, 'comment on') }
754 }
755
756 let refs: Json = {}
757
758 try {
759 refs = record(record(JSON.parse(request.stdout)).diff_refs)
760 } catch {
761 refs = {}
762 }
763
764 const baseSha = text(refs.base_sha)
765 const startSha = text(refs.start_sha)
766 const headSha = text(refs.head_sha)
767
768 if (baseSha === '' || startSha === '' || headSha === '') {
769 return { error: `The comment could not be posted on ${place.label}: glab did not say which commits it compares` }
770 }
771
772 if (headSha !== at.commit) {
773 return { error: 'This merge request has changed since it was opened here: reopen it, then comment again' }
774 }
775
776 // GitLab places a note by old and new line together. Git knows both when the commits are here;
777 // when they are not, the new line alone is sent, which GitLab accepts for added lines.
778 const outside = { error: `Line ${at.line} of ${at.path} is not part of this merge request's diff` }
779 let oldPath = at.path
780 let oldLine = 0
781
782 const changed = await call(run, ['git', 'diff', '--name-status', '-M', '-z', baseSha, headSha, '--'], LOCAL_MS)
783
784 if (changed.exitCode === 0) {
785 const fields = changed.stdout.split('\0')
786 let status = ''
787
788 for (let index = 0; index < fields.length - 1; ) {
789 const kind = fields[index] ?? ''
790 const isMoved = /^[RC]/.test(kind)
791 const from = fields[index + 1] ?? ''
792 const to = isMoved ? (fields[index + 2] ?? '') : from
793
794 if (to === at.path) {
795 status = kind
796 oldPath = from
797 }
798
799 index += isMoved ? 3 : 2
800 }
801
802 if (status === '' || status.startsWith('D')) {
803 return outside
804 }
805
806 if (!status.startsWith('A')) {
807 const diff = await call(
808 run,
809 ['git', 'diff', '--no-color', '--no-ext-diff', '-U0', '-M', baseSha, headSha, '--', ...new Set([oldPath, at.path])],
810 LOCAL_MS,
811 )
812 const where = diff.exitCode === 0 ? oldLineOf(diff.stdout, at.line) : { isAdded: true as const }
813
814 oldLine = where.isAdded ? 0 : where.oldLine
815 }
816 }
817
818 // glab sends fields as a JSON body, where a name like position[new_line] would be one literal key
819 // that GitLab ignores; -F parses a JSON object, so the position travels as one.
820 const position = {
821 position_type: 'text',
822 base_sha: baseSha,
823 start_sha: startSha,
824 head_sha: headSha,
825 old_path: oldPath,
826 new_path: at.path,
827 new_line: at.line,
828 ...(oldLine > 0 ? { old_line: oldLine } : {}),
829 }
830
831 const posted = await call(
832 run,
833 glab(
834 place,
835 '-X',
836 'POST',
837 `${gitlabRequest(place)}/discussions`,
838 '-f',
839 `body=${body}`,
840 '-F',
841 `position=${JSON.stringify(position)}`,
842 ),
843 )
844
845 if (posted.exitCode !== 0) {
846 return { error: whyFailed(place, posted, 'comment on', at) }
847 }
848
849 try {
850 const comment = fromGitlabDiscussion(JSON.parse(posted.stdout), headSha)[0]
851
852 if (!comment) {
853 throw new Error('no note')
854 }
855
856 // An older glab that does not send the position would post a general comment instead.
857 return comment.path === ''
858 ? { error: `The comment was posted on ${place.label}, but not on the line: update glab, and move it on GitLab` }
859 : { comment }
860 } catch {
861 return { error: `The comment may have been posted on ${place.label}, but glab's answer could not be read: reload to check` }
862 }
863}
864
865// ---- The three entry points ----
866
867// Every comment on the request, oldest first: review comments on lines, and the general conversation.
868// `typed` is what the person typed to open the request ("#12", a URL, ...), as resolveRequest takes.
869// The open pull or merge request whose head is a branch of this repo, by
870// what a person would type to open it ("#12") and how it is spoken of
871// ("PR #12"); undefined when there is none, or no forge to ask.
872export const requestOfBranch = async (
873 run: Run,
874 branch: string,
875): Promise<
876 { typed: string; label: string; title: string; baseRef: string; url: string } | undefined
877> => {
878 if (branch === '' || branch === 'HEAD') {
879 return undefined
880 }
881
882 const place = await locate(run, '1')
883
884 if ('error' in place) {
885 return undefined
886 }
887
888 const isGitlab = place.forge === 'gitlab'
889 const asked = await call(
890 run,
891 isGitlab
892 ? glab(
893 place,
894 `projects/${encodeURIComponent(place.repo)}/merge_requests?state=opened&source_branch=${encodeURIComponent(branch)}`,
895 )
896 : gh(
897 place,
898 `repos/${place.repo}/pulls?state=open&head=${encodeURIComponent(`${place.repo.split('/')[0] ?? ''}:${branch}`)}`,
899 ),
900 20_000,
901 )
902
903 if (asked.exitCode !== 0) {
904 return undefined
905 }
906
907 try {
908 const first = record(values(asked.stdout)[0])
909 const number = whole(isGitlab ? first.iid : first.number)
910
911 // What it is called, and the branch it asks to be merged into.
912 const title = text(first.title)
913 const baseRef = isGitlab ? text(first.target_branch) : text(record(first.base).ref)
914 // Its page on the forge.
915 const url = isGitlab ? text(first.web_url) : text(first.html_url)
916
917 return number === 0
918 ? undefined
919 : isGitlab
920 ? { typed: `!${number}`, label: `MR !${number}`, title, baseRef, url }
921 : { typed: `#${number}`, label: `PR #${number}`, title, baseRef, url }
922 } catch {
923 return undefined
924 }
925}
926
927// The repo's open pull or merge requests, newest first, each by what a person
928// would type to open it ("#12", "!34") and its title. None where there is no
929// forge to ask, or it does not answer: a list to offer, never an error.
930export const listRequests = async (run: Run): Promise<{ typed: string; title: string }[]> => {
931 const place = await locate(run, '1')
932
933 if ('error' in place) {
934 return []
935 }
936
937 const listed = await call(
938 run,
939 place.forge === 'gitlab'
940 ? glab(place, `projects/${encodeURIComponent(place.repo)}/merge_requests?state=opened&per_page=30`)
941 : gh(place, `repos/${place.repo}/pulls?state=open&per_page=30`),
942 20_000,
943 )
944
945 if (listed.exitCode !== 0) {
946 return []
947 }
948
949 try {
950 return values(listed.stdout).flatMap(raw => {
951 const one = record(raw)
952 const number = whole(place.forge === 'gitlab' ? one.iid : one.number)
953
954 return number === 0
955 ? []
956 : [{ typed: `${place.forge === 'gitlab' ? '!' : '#'}${number}`, title: text(one.title) }]
957 })
958 } catch {
959 return []
960 }
961}
962
963export const fetchComments = async (run: Run, typed: string): Promise<{ comments: Comment[] } | { error: string }> => {
964 const place = await locate(run, typed)
965
966 if ('error' in place) {
967 return place
968 }
969
970 return place.forge === 'github' ? githubComments(run, place) : gitlabComments(run, place)
971}
972
973// Posts one review comment on a line of the request's head version of a file. `at.commit` is the
974// full hash of the head commit the comment is anchored to; `at.path` is relative to the repo root.
975export const postComment = async (
976 run: Run,
977 typed: string,
978 at: { path: string; line: number; commit: string },
979 body: string,
980): Promise<{ comment: Comment } | { error: string }> => {
981 if (body.trim() === '') {
982 return { error: 'Write something before posting the comment' }
983 }
984
985 if (at.path === '' || !Number.isInteger(at.line) || at.line < 1 || at.commit === '') {
986 return { error: 'Pick a line of a file in the request to comment on' }
987 }
988
989 const place = await locate(run, typed)
990
991 if ('error' in place) {
992 return place
993 }
994
995 if (place.forge === 'gitlab') {
996 return gitlabPost(run, place, at, body)
997 }
998
999 // -f keeps the body as typed (-F would read "@file" and turn "true" into a boolean); the line must be a number.
1000 const posted = await call(
1001 run,
1002 gh(
1003 place,
1004 '-X',
1005 'POST',
1006 `repos/${place.repo}/pulls/${place.number}/comments`,
1007 '-f',
1008 `body=${body}`,
1009 '-f',
1010 `commit_id=${at.commit}`,
1011 '-f',
1012 `path=${at.path}`,
1013 '-F',
1014 `line=${at.line}`,
1015 '-f',
1016 'side=RIGHT',
1017 ),
1018 )
1019
1020 if (posted.exitCode !== 0) {
1021 return { error: whyFailed(place, posted, 'comment on', at) }
1022 }
1023
1024 try {
1025 return { comment: fromGithubLine(JSON.parse(posted.stdout), new Map()) }
1026 } catch {
1027 return { error: `The comment may have been posted on ${place.label}, but gh's answer could not be read: reload to check` }
1028 }
1029}
1030
1031// Answers a review thread. `root` is the thread's first comment: GitHub replies to that comment
1032// by its id, GitLab adds a note to the discussion (`root.thread`).
1033export const replyComment = async (
1034 run: Run,
1035 typed: string,
1036 root: Pick<Comment, 'id' | 'thread'>,
1037 body: string,
1038): Promise<{ comment: Comment } | { error: string }> => {
1039 if (body.trim() === '') {
1040 return { error: 'Write something before posting the reply' }
1041 }
1042
1043 const place = await locate(run, typed)
1044
1045 if ('error' in place) {
1046 return place
1047 }
1048
1049 if (place.forge === 'gitlab' && (root.thread ?? '') === '') {
1050 return { error: 'GitLab did not say which discussion that comment is in: refresh (r) and try again' }
1051 }
1052
1053 const posted = await call(
1054 run,
1055 place.forge === 'gitlab'
1056 ? glab(
1057 place,
1058 '-X',
1059 'POST',
1060 `${gitlabRequest(place)}/discussions/${root.thread ?? ''}/notes`,
1061 '-f',
1062 `body=${body}`,
1063 )
1064 : gh(
1065 place,
1066 '-X',
1067 'POST',
1068 `repos/${place.repo}/pulls/${place.number}/comments/${root.id}/replies`,
1069 '-f',
1070 `body=${body}`,
1071 ),
1072 )
1073
1074 if (posted.exitCode !== 0) {
1075 return { error: whyFailed(place, posted, 'comment on') }
1076 }
1077
1078 try {
1079 const made =
1080 place.forge === 'gitlab'
1081 ? fromGitlabNote(JSON.parse(posted.stdout), root.id, '', root.thread ?? '')
1082 : fromGithubLine(JSON.parse(posted.stdout), new Map())
1083
1084 return { comment: { ...made, replyTo: root.id } }
1085 } catch {
1086 return { error: `The reply may have been posted on ${place.label}, but the answer could not be read: refresh to check` }
1087 }
1088}
1089
1090// Marks a review thread resolved, or open again. Answers '' when the forge took it, else why not.
1091export const resolveThread = async (
1092 run: Run,
1093 typed: string,
1094 thread: string,
1095 isResolved: boolean,
1096): Promise<string> => {
1097 const place = await locate(run, typed)
1098
1099 if ('error' in place) {
1100 return place.error
1101 }
1102
1103 if (thread === '') {
1104 return `${place.label} did not say which thread that comment is in: refresh (r) and try again`
1105 }
1106
1107 const verb = isResolved ? 'resolveReviewThread' : 'unresolveReviewThread'
1108 const ran = await call(
1109 run,
1110 place.forge === 'gitlab'
1111 ? glab(
1112 place,
1113 '-X',
1114 'PUT',
1115 `${gitlabRequest(place)}/discussions/${thread}?resolved=${isResolved ? 'true' : 'false'}`,
1116 )
1117 : gh(
1118 place,
1119 'graphql',
1120 '-f',
1121 `query=mutation($id:ID!){${verb}(input:{threadId:$id}){thread{isResolved}}}`,
1122 '-f',
1123 `id=${thread}`,
1124 ),
1125 )
1126
1127 return ran.exitCode === 0 ? '' : whyFailed(place, ran, 'comment on')
1128}
1129
1130// Submits a review of the request: an approval, a request for changes, or a
1131// comment, with a summary. Answers '' when the forge took it, else why not.
1132export const submitReview = async (
1133 run: Run,
1134 typed: string,
1135 verdict: 'approve' | 'request-changes' | 'comment',
1136 summary: string,
1137): Promise<string> => {
1138 const body = summary.trim()
1139
1140 if (verdict !== 'approve' && body === '') {
1141 return 'Write a summary first: it is what the review says'
1142 }
1143
1144 const place = await locate(run, typed)
1145
1146 if ('error' in place) {
1147 return place.error
1148 }
1149
1150 if (place.forge === 'gitlab') {
1151 if (verdict === 'request-changes') {
1152 return 'GitLab has no call for requesting changes here: submit a comment saying what to change'
1153 }
1154
1155 if (verdict === 'approve') {
1156 const approved = await call(run, glab(place, '-X', 'POST', `${gitlabRequest(place)}/approve`))
1157
1158 if (approved.exitCode !== 0) {
1159 return whyFailed(place, approved, 'comment on')
1160 }
1161 }
1162
1163 if (body === '') {
1164 return ''
1165 }
1166
1167 const noted = await call(
1168 run,
1169 glab(place, '-X', 'POST', `${gitlabRequest(place)}/notes`, '-f', `body=${body}`),
1170 )
1171
1172 return noted.exitCode === 0 ? '' : whyFailed(place, noted, 'comment on')
1173 }
1174
1175 const event = verdict === 'approve' ? 'APPROVE' : verdict === 'comment' ? 'COMMENT' : 'REQUEST_CHANGES'
1176 const sent = await call(
1177 run,
1178 gh(
1179 place,
1180 '-X',
1181 'POST',
1182 `repos/${place.repo}/pulls/${place.number}/reviews`,
1183 '-f',
1184 `event=${event}`,
1185 ...(body === '' ? [] : ['-f', `body=${body}`]),
1186 ),
1187 )
1188
1189 return sent.exitCode === 0 ? '' : whyFailed(place, sent, 'comment on')
1190}
1191
1192// The sub-folder of the repo that `run` executes in, as a prefix ('' at the root, 'frontend/' in a
1193// sub-folder): the pane may be reviewing a sub-folder, and forge paths are relative to the root.
1194export const repoPrefix = async (run: Run): Promise<string> => {
1195 const ran = await call(run, ['git', 'rev-parse', '--show-prefix'], LOCAL_MS)
1196
1197 return ran.exitCode === 0 ? ran.stdout.trim() : ''
1198}
1199hooks/run.ts 20 lines1// How a module that holds no engine handle runs a command. The hooks module,
2// which holds the handle, passes each such module a `Run`; everything below
3// it knows processes only through this.
4
5export type Ran = { exitCode: number; stdout: string; stderr: string }
6
7// Runs a command and never rejects: one that could not be started answers
8// exit code -1 with the reason as its stderr, so a caller has one thing to
9// check and nothing to catch.
10export type Run = (
11 argv: string[],
12 init?: { cwd?: string; stdin?: string; timeoutMs?: number },
13) => Promise<Ran>
14
15// More files than this are not handed to one command.
16export const FILE_LIMIT = 300
17
18// The last words of what a command printed, short enough for a note or a toast.
19export const tail = (text: string): string => text.trim().split('\n').slice(-2).join(' ').slice(0, 200)
20