SLOPSHOPPER

lens

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…

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-06BuddyLim/claude-code-mod-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lens
│ ┃ Lens ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to review here │ ┃ This folder is not inside a git ⏺ Read(src/auth.ts) │ ┃ repository, and no repo has been ⎿ Read 6 lines │ ┃ reviewed yet. ⏺ Update(src/auth.ts) │ ┃ Name one: /lens ~/Code/my-repo [base ⎿ Added 2 lines, removed 1 line │ ┃ branch or commit] ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lens │ ⎿ lens: This folder is not inside a git repository. Name one: /len │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Lens
Nothing to review here This folder is not inside a git repository, and no repo has been reviewed yet. Name one: /lens ~/Code/my-repo [base branch or commit]
README

lens

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.

What it does

  • Changed files with problems inline. /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.
  • New or pre-existing. The base's version of each changed file is checked too, so every problem is marked as brought by the change or already there.
  • A file view. The whole file with syntax colours, diagnostics under the lines they are on, a minimap, find, blame, a breadcrumb with the file's outline, inlay hints, and a diff view that interleaves what the base had. Markdown is shown rendered.
  • Look things up. Select a name to see its type and where it is defined, then list its uses, callers, callees and implementations, or search the project's names.
  • A git graph. Branches, stashes and uncommitted work, with each commit's files a press away. Check out a branch or commit, undo the last commit, or compare any two branches, commits or pull requests.
  • Commit, stash and discard the files you tick, from the file tree.
  • Pull and merge requests. Compare with #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.
  • Worktrees. Each other worktree of the repo is a badge on the commit it has checked out; press it to review that worktree where you left it. Compare with @name to read your files against another worktree's as they stand, uncommitted work included.
  • Review findings from the ledger mod, where it is installed. Each finding a reviewing agent records shows on its line as a thread does, counts among the file's threads, and is listed with them; a fixed one shows as resolved. They cannot be answered or resolved from here: the ledger closes them. Without the ledger, nothing changes.
  • Hand things to Claude. Send a line's problems, a function, your selection or every new issue to the prompt (appended, never submitted), or have Claude told automatically what its own edits broke.
  • Picks up where you left off. Each repo's comparison and layout are kept between sessions; run outside a repo, /lens offers the recent ones.

Use

/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.

Settings

/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.

Other languages

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:

LanguageServerA project is the folder holding
C and C++clangdcompile_commands.json, compile_flags.txt, .clangd, CMakeLists.txt
C#csharp-ls, else OmniSharp -lsp*.sln, *.csproj
Gogoplsgo.work, go.mod
Rustrust-analyzerCargo.toml
Pulumi YAMLpulumi-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.

FieldWhat it is
extensionsThe file extensions the server reads, each with its dot. Upper or lower case is the same.
filenamesWhole file names it reads (* and ? stand for anything). A name counts before any extension.
commandThe 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.
languageIdWhat the protocol calls the language: "go", or one for each extension ({ ".c": "c", ".cc": "cpp" }). Left out, it is the extension without its dot.
rootMarkersFiles 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.
languageWhat to call the language in a note. Left out, it is the server's name.
installHow to get the server, shown when it is not installed.
disabledtrue 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.

Requirements

Nothing is required beyond git; each checker is used where it is found.

  • Python: uv (ruff and pyright run through uvx)
  • TypeScript: the project's own node_modules (tsc, eslint) and Node
  • Terraform: terraform, and optionally terraform-ls
  • Other languages: that language's server (see Other languages)
  • Syntax colours: uv (Pygments runs through it)
  • Pull requests: gh or glab, signed in
  • Icons: a Nerd Font in your terminal

Install

This 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

What it leaves on your machine

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.

Develop

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).

License

MIT

Source 34 files
hooks/register.tsx 2485 lines
1// 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}
1200
hooks/check.ts 870 lines
1// 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}
870
hooks/diags.ts 39 lines
1// 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'
39
hooks/git.ts 927 lines
1// 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}
927
hooks/lists.ts 164 lines
1// 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}
164
hooks/lsp.ts 841 lines
1// 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}
841
hooks/lsp-types.ts 61 lines
1// 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 }
61
hooks/prompt.ts 89 lines
1// 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')
89
hooks/recents.ts 126 lines
1// 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  })
126
hooks/ledger.ts 77 lines
1// 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  })
77
hooks/review.ts 1199 lines
1// 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}
1199
hooks/run.ts 20 lines
1// 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