@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
package/src/upgrade.ts ADDED
@@ -0,0 +1,338 @@
1
+ // `pr-review upgrade`: brings pr-review, acpx, and the project's copy of the skill up to date. It
2
+ // says what it will change and asks first. Once pr-review itself moves, the new version checks the
3
+ // skill, so the skill it ships is stamped by the code that ships it.
4
+ import { realpath } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import { parseArgs } from 'node:util'
7
+ import { z } from 'zod'
8
+ import { type CliIo, EXIT, printJson, UsageError } from './commands.js'
9
+ import { findSkillCopies, type ReadSkill, type SkillCopy } from './review/doctor.js'
10
+ import { installSkill, SkillDirExistsError } from './review/install-skill.js'
11
+
12
+ export const ACPX_PACKAGE = 'acpx'
13
+
14
+ export interface CommandResult {
15
+ ok: boolean
16
+ stdout: string
17
+ stderr: string
18
+ }
19
+
20
+ export interface UpgradeDeps {
21
+ /** The running pr-review: its package name, version, and where it is installed. */
22
+ packageName: string
23
+ version: string
24
+ packageRoot: string
25
+ /** The repository whose skill copies are refreshed, or null outside one. */
26
+ repoRoot: string | null
27
+ acpxVersion: () => Promise<string | null>
28
+ /** The acpx that PATH runs, which chat uses. */
29
+ acpxPath: string | null
30
+ /** Runs one command without a shell. Only `npm` is run. */
31
+ run: (file: string, args: string[]) => Promise<CommandResult>
32
+ /** Runs `pr-review <args>` from the package on disk, which is the new one after its upgrade. */
33
+ runInstalled: (args: string[]) => Promise<CommandResult>
34
+ /** Asks a yes/no question, or returns null when there is no one to ask. */
35
+ confirm: (question: string) => Promise<boolean | null>
36
+ readSkill?: ReadSkill
37
+ }
38
+
39
+ /** What one step upgrades, in the order a plan runs them. */
40
+ const StepKindSchema = z.enum(['package', 'acpx', 'skill'])
41
+ type StepKind = z.infer<typeof StepKindSchema>
42
+
43
+ const NpmStepSchema = z.object({
44
+ kind: z.enum(['package', 'acpx']),
45
+ name: z.string(),
46
+ from: z.string(),
47
+ to: z.string(),
48
+ })
49
+ const SkillStepSchema = z.object({ kind: z.literal('skill'), paths: z.array(z.string()) })
50
+ const OutcomeFields = {
51
+ status: z.enum(['done', 'failed']),
52
+ detail: z.string().optional(),
53
+ }
54
+ const StepOutcomeSchema = z.discriminatedUnion('kind', [
55
+ NpmStepSchema.extend(OutcomeFields),
56
+ SkillStepSchema.extend(OutcomeFields),
57
+ ])
58
+ /** What an applied upgrade prints, and so what the parent reads back from the new version. */
59
+ const AppliedReportSchema = z.object({ steps: z.array(StepOutcomeSchema) })
60
+
61
+ export type UpgradeStep = z.infer<typeof NpmStepSchema> | z.infer<typeof SkillStepSchema>
62
+ export type StepOutcome = z.infer<typeof StepOutcomeSchema>
63
+
64
+ export interface UpgradePlan {
65
+ steps: UpgradeStep[]
66
+ /** What was looked at and left alone, and why. */
67
+ notes: string[]
68
+ }
69
+
70
+ /** `1.2.10` against `1.2.9`, numerically. A prerelease tag is ignored. */
71
+ export function isNewer(candidate: string, current: string): boolean {
72
+ const parts = (v: string): number[] => v.trim().replace(/^v/, '').split(/[-+]/)[0]!.split('.').map(Number)
73
+ const a = parts(candidate)
74
+ const b = parts(current)
75
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
76
+ const diff = (a[i] ?? 0) - (b[i] ?? 0)
77
+ if (Number.isNaN(diff)) return false
78
+ if (diff !== 0) return diff > 0
79
+ }
80
+ return false
81
+ }
82
+
83
+ async function latestVersion(deps: UpgradeDeps, name: string): Promise<string | null> {
84
+ const result = await deps.run('npm', ['view', name, 'version'])
85
+ const version = result.stdout.trim()
86
+ return result.ok && version !== '' ? version : null
87
+ }
88
+
89
+ /**
90
+ * Whether `installed` lies in the copy of `name` that `npm install -g` replaces. Installing over
91
+ * any other copy (pnpm, brew, another node) would leave the one in use as it was.
92
+ */
93
+ async function ownedByGlobalNpm(
94
+ globalRoot: string | null,
95
+ name: string,
96
+ installed: string
97
+ ): Promise<boolean> {
98
+ if (globalRoot === null) return false
99
+ try {
100
+ const globalCopy = await realpath(path.join(globalRoot, name))
101
+ const real = await realpath(installed)
102
+ return real === globalCopy || real.startsWith(`${globalCopy}${path.sep}`)
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ function notGlobalNote(name: string, latest: string, installed: string): string {
109
+ return `${name} ${latest} is out, but the copy in use (${installed}) is not the global npm install; update it the way you installed it`
110
+ }
111
+
112
+ async function skillCopies(deps: UpgradeDeps): Promise<SkillCopy[]> {
113
+ return deps.repoRoot === null ? [] : findSkillCopies(deps.repoRoot, deps.readSkill)
114
+ }
115
+
116
+ export async function planUpgrade(deps: UpgradeDeps): Promise<UpgradePlan> {
117
+ const steps: UpgradeStep[] = []
118
+ const notes: string[] = []
119
+
120
+ const root = await deps.run('npm', ['root', '-g'])
121
+ const globalRoot = root.ok ? root.stdout.trim() : null
122
+ const latest = await latestVersion(deps, deps.packageName)
123
+ if (latest === null) {
124
+ notes.push(`${deps.packageName}: could not read the latest version from npm`)
125
+ } else if (!isNewer(latest, deps.version)) {
126
+ notes.push(`${deps.packageName} ${deps.version} is up to date`)
127
+ } else if (await ownedByGlobalNpm(globalRoot, deps.packageName, deps.packageRoot)) {
128
+ steps.push({ kind: 'package', name: deps.packageName, from: deps.version, to: latest })
129
+ } else {
130
+ notes.push(notGlobalNote(deps.packageName, latest, deps.packageRoot))
131
+ }
132
+
133
+ const acpx = deps.acpxPath === null ? null : await deps.acpxVersion()
134
+ const acpxLatest = acpx === null ? null : await latestVersion(deps, ACPX_PACKAGE)
135
+ if (acpx === null || deps.acpxPath === null) {
136
+ notes.push('acpx is not installed; AI Chat needs it: npm install -g acpx@latest')
137
+ } else if (acpxLatest === null) {
138
+ notes.push('acpx: could not read the latest version from npm')
139
+ } else if (!isNewer(acpxLatest, acpx)) {
140
+ notes.push(`acpx ${acpx} is up to date`)
141
+ } else if (await ownedByGlobalNpm(globalRoot, ACPX_PACKAGE, deps.acpxPath)) {
142
+ steps.push({ kind: 'acpx', name: ACPX_PACKAGE, from: acpx, to: acpxLatest })
143
+ } else {
144
+ notes.push(notGlobalNote(ACPX_PACKAGE, acpxLatest, deps.acpxPath))
145
+ }
146
+
147
+ if (deps.repoRoot === null) {
148
+ notes.push('not in a repository, so no project skill to refresh')
149
+ } else {
150
+ const copies = await skillCopies(deps)
151
+ // A new pr-review can ship a new skill, so every copy is in question once the package moves.
152
+ const packageMoves = steps.some(step => step.kind === 'package')
153
+ const toCheck = copies.filter(copy => packageMoves || copy.stale)
154
+ if (copies.length === 0) {
155
+ notes.push('the project has no copy of the skill; run `pr-review install-skill` to add one')
156
+ } else if (toCheck.length > 0) {
157
+ steps.push({ kind: 'skill', paths: toCheck.map(copy => copy.path) })
158
+ } else {
159
+ notes.push(`the project skill matches pr-review ${deps.version}`)
160
+ }
161
+ }
162
+ return { steps, notes }
163
+ }
164
+
165
+ export function describeStep(step: UpgradeStep): string {
166
+ if (step.kind === 'skill') {
167
+ return `refresh the project skill where it differs from pr-review's: ${step.paths.join(', ')}`
168
+ }
169
+ return `upgrade ${step.name} ${step.from} -> ${step.to} (npm install -g ${step.name}@${step.to})`
170
+ }
171
+
172
+ async function applySkill(deps: UpgradeDeps): Promise<{ written: string[]; skipped: string[] }> {
173
+ const written: string[] = []
174
+ const skipped: string[] = []
175
+ for (const copy of await skillCopies(deps)) {
176
+ if (!copy.stale) continue
177
+ try {
178
+ await installSkill({ targets: [{ kind: copy.kind, dir: copy.dir }] })
179
+ written.push(copy.path)
180
+ } catch (err) {
181
+ if (!(err instanceof SkillDirExistsError)) throw err
182
+ skipped.push(copy.path)
183
+ }
184
+ }
185
+ return { written, skipped }
186
+ }
187
+
188
+ async function applySkillStep(
189
+ step: z.infer<typeof SkillStepSchema>,
190
+ deps: UpgradeDeps
191
+ ): Promise<StepOutcome> {
192
+ const { written, skipped } = await applySkill(deps)
193
+ if (skipped.length > 0) {
194
+ return {
195
+ ...step,
196
+ paths: written,
197
+ status: 'failed',
198
+ detail: `not a managed copy, left alone: ${skipped.join(', ')}; run \`pr-review install-skill --force\` to replace it`,
199
+ }
200
+ }
201
+ return { ...step, paths: written, status: 'done' }
202
+ }
203
+
204
+ /**
205
+ * The new pr-review's own `upgrade --yes --only <kinds>`: it plans again with its own code, and runs
206
+ * only the kinds of step the user confirmed. Its report's steps join this one's, so this process
207
+ * stays the one that prints them.
208
+ */
209
+ async function handOff(deps: UpgradeDeps, kinds: StepKind[]): Promise<StepOutcome[]> {
210
+ const result = await deps.runInstalled([
211
+ 'upgrade',
212
+ '--yes',
213
+ '--only',
214
+ kinds.join(','),
215
+ ...(deps.repoRoot === null ? [] : ['--repo', deps.repoRoot]),
216
+ ])
217
+ let report: unknown
218
+ try {
219
+ // The report is the last line; String() turns a missing one into text JSON.parse rejects.
220
+ report = JSON.parse(String(result.stdout.trim().split('\n').at(-1)))
221
+ } catch {
222
+ report = null
223
+ }
224
+ const parsed = AppliedReportSchema.safeParse(report)
225
+ if (parsed.success) return parsed.data.steps
226
+ return [
227
+ {
228
+ kind: 'skill',
229
+ paths: [],
230
+ status: 'failed',
231
+ detail:
232
+ `the new pr-review did not report a result; run \`pr-review upgrade\` again\n` +
233
+ result.stderr.trim().split('\n').slice(-3).join('\n'),
234
+ },
235
+ ]
236
+ }
237
+
238
+ /**
239
+ * Runs every step in order. A failed npm install does not stop the rest. Once pr-review itself is
240
+ * upgraded, the new version plans and runs whatever is left, so the skill it ships is checked and
241
+ * copied by its own code.
242
+ */
243
+ export async function applyUpgrade(plan: UpgradePlan, deps: UpgradeDeps): Promise<StepOutcome[]> {
244
+ const outcomes: StepOutcome[] = []
245
+ for (const [index, step] of plan.steps.entries()) {
246
+ if (step.kind === 'skill') {
247
+ outcomes.push(await applySkillStep(step, deps))
248
+ continue
249
+ }
250
+ const result = await deps.run('npm', ['install', '-g', `${step.name}@${step.to}`])
251
+ if (!result.ok) {
252
+ const detail = result.stderr.trim().split('\n').slice(-3).join('\n') || 'npm install failed'
253
+ outcomes.push({ ...step, status: 'failed', detail })
254
+ continue
255
+ }
256
+ outcomes.push({ ...step, status: 'done' })
257
+ const rest = plan.steps.slice(index + 1)
258
+ if (step.kind === 'package' && rest.length > 0) {
259
+ return [
260
+ ...outcomes,
261
+ ...(await handOff(
262
+ deps,
263
+ rest.map(later => later.kind)
264
+ )),
265
+ ]
266
+ }
267
+ }
268
+ return outcomes
269
+ }
270
+
271
+ /** `--only package,acpx,skill`: the kinds of step this run may take. */
272
+ function parseOnly(raw: string | undefined): ReadonlySet<StepKind> | null {
273
+ if (raw === undefined) return null
274
+ const kinds = z.array(StepKindSchema).safeParse(raw.split(','))
275
+ if (!kinds.success) {
276
+ throw new UsageError(`--only takes a comma list of ${StepKindSchema.options.join(', ')}; got "${raw}"`)
277
+ }
278
+ return new Set(kinds.data)
279
+ }
280
+
281
+ /**
282
+ * `upgrade [--yes] [--only <kinds>]`: the plan on stderr, a confirmation, then one JSON line with
283
+ * what happened.
284
+ */
285
+ export async function runUpgrade(deps: UpgradeDeps, argv: string[], io: CliIo): Promise<number> {
286
+ const { values } = parseArgs({
287
+ args: argv,
288
+ options: { yes: { type: 'boolean', short: 'y' }, only: { type: 'string' } },
289
+ strict: true,
290
+ })
291
+ const only = parseOnly(values.only)
292
+ const full = await planUpgrade(deps)
293
+ const plan: UpgradePlan =
294
+ only === null
295
+ ? full
296
+ : {
297
+ steps: full.steps.filter(step => only.has(step.kind)),
298
+ notes: [
299
+ ...full.notes,
300
+ ...full.steps
301
+ .filter(step => !only.has(step.kind))
302
+ .map(step => `left out by --only: ${describeStep(step)}`),
303
+ ],
304
+ }
305
+ for (const note of plan.notes) io.stderr(` ${note}`)
306
+ if (plan.steps.length === 0) {
307
+ io.stderr('Everything is up to date.')
308
+ printJson(io, { applied: false, steps: [], notes: plan.notes })
309
+ return EXIT.ok
310
+ }
311
+ io.stderr('pr-review upgrade will:')
312
+ for (const step of plan.steps) io.stderr(` - ${describeStep(step)}`)
313
+ if (plan.steps.some(step => step.kind === 'package')) {
314
+ io.stderr(' Once pr-review is upgraded, the new version checks and runs the steps after it.')
315
+ }
316
+
317
+ const confirmed = values.yes === true ? true : await deps.confirm('Proceed? [y/N] ')
318
+ if (confirmed !== true) {
319
+ io.stderr(confirmed === null ? 'Nothing changed. Re-run with --yes to apply.' : 'Nothing changed.')
320
+ printJson(io, { applied: false, steps: plan.steps, notes: plan.notes })
321
+ return EXIT.ok
322
+ }
323
+
324
+ const outcomes = await applyUpgrade(plan, deps)
325
+ for (const outcome of outcomes) {
326
+ io.stderr(
327
+ ` ${outcome.status}: ${describeStep(outcome)}${outcome.detail ? `\n ${outcome.detail}` : ''}`
328
+ )
329
+ }
330
+ // A skill step lists only the copies it wrote, failed or not.
331
+ const refreshed = outcomes.flatMap(o => (o.kind === 'skill' ? o.paths : []))
332
+ if (refreshed.length > 0) {
333
+ io.stderr(`The project skill changed. Commit and push ${refreshed.join(' and ')} so your team gets it.`)
334
+ }
335
+ const ok = outcomes.every(o => o.status !== 'failed')
336
+ printJson(io, { applied: true, ok, steps: outcomes, notes: plan.notes })
337
+ return ok ? EXIT.ok : EXIT.error
338
+ }
package/static/js/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  /** @typedef {import('./contract-types.js').ErrorEnvelope} ErrorEnvelope */
3
+ /** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
3
4
  /** @typedef {import('./contract-types.js').PrBundle} PrBundle */
4
5
  /** @typedef {import('./contract-types.js').PatchesResponse} PatchesResponse */
5
6
 
@@ -69,17 +70,20 @@ export async function fetchJson(url, opts = {}) {
69
70
  }
70
71
 
71
72
  /**
72
- * @param {number} prNumber
73
- * @param {{ refresh?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
73
+ * The bundle for one target. `refresh` asks the server to read the forge, or the working tree,
74
+ * again; `poll` says this is the background poller, which is answered from the head the page was
75
+ * opened with rather than by snapshotting the working tree every few seconds.
76
+ * @param {ReviewKey} prNumber
77
+ * @param {{ refresh?: boolean, poll?: boolean, fetchImpl?: typeof fetch | undefined }} [opts]
74
78
  * @returns {Promise<PrBundle>}
75
79
  */
76
80
  export function fetchBundle(prNumber, opts = {}) {
77
- const q = opts.refresh ? '?refresh=1' : ''
81
+ const q = opts.refresh ? '?refresh=1' : opts.poll ? '?poll=1' : ''
78
82
  return fetchJson(`/api/prs/${prNumber}${q}`, { fetchImpl: opts.fetchImpl })
79
83
  }
80
84
 
81
85
  /**
82
- * @param {number} prNumber
86
+ * @param {ReviewKey} prNumber
83
87
  * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
84
88
  * @returns {Promise<PatchesResponse>}
85
89
  */
@@ -89,7 +93,7 @@ export function fetchPatches(prNumber, opts = {}) {
89
93
  }
90
94
 
91
95
  /**
92
- * @param {number} prNumber
96
+ * @param {ReviewKey} prNumber
93
97
  * @param {{ fetchImpl?: typeof fetch }} [opts]
94
98
  * @returns {Promise<import('./contract-types.js').StateResponse>}
95
99
  */
@@ -98,14 +102,18 @@ export function fetchState(prNumber, opts = {}) {
98
102
  }
99
103
 
100
104
  /**
101
- * @param {number} prNumber
105
+ * @param {ReviewKey} prNumber
102
106
  * @param {string} id `layer:<id>` or `layer:<id>/file:<key>`
103
107
  * @param {boolean} reviewed
104
- * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
108
+ * @param {{ headSha?: string, canvasSha?: string, fetchImpl?: typeof fetch }} [opts]
105
109
  * @returns {Promise<import('./contract-types.js').StateResponse>}
106
110
  */
107
111
  export function putReviewed(prNumber, id, reviewed, opts = {}) {
108
- const body = opts.headSha === undefined ? { reviewed } : { reviewed, headSha: opts.headSha }
112
+ const body = {
113
+ reviewed,
114
+ ...(opts.headSha === undefined ? {} : { headSha: opts.headSha }),
115
+ ...(opts.canvasSha === undefined ? {} : { canvasSha: opts.canvasSha }),
116
+ }
109
117
  return fetchJson(`/api/prs/${prNumber}/reviewed/${id}`, {
110
118
  method: 'PUT',
111
119
  body,
@@ -114,7 +122,7 @@ export function putReviewed(prNumber, id, reviewed, opts = {}) {
114
122
  }
115
123
 
116
124
  /**
117
- * @param {number} prNumber
125
+ * @param {ReviewKey} prNumber
118
126
  * @param {string} fingerprint
119
127
  * @param {boolean} dismissed
120
128
  * @param {{ reason?: string, fetchImpl?: typeof fetch }} [opts]
@@ -130,7 +138,7 @@ export function putDismissed(prNumber, fingerprint, dismissed, opts = {}) {
130
138
  }
131
139
 
132
140
  /**
133
- * @param {number} prNumber
141
+ * @param {ReviewKey} prNumber
134
142
  * @param {number} rootCommentId
135
143
  * @param {boolean} hidden
136
144
  * @param {{ fetchImpl?: typeof fetch }} [opts]
@@ -145,7 +153,7 @@ export function putThreadHidden(prNumber, rootCommentId, hidden, opts = {}) {
145
153
  }
146
154
 
147
155
  /**
148
- * @param {number} prNumber
156
+ * @param {ReviewKey} prNumber
149
157
  * @param {{ refresh?: boolean, fetchImpl?: typeof fetch }} [opts]
150
158
  * @returns {Promise<import('./contract-types.js').Capabilities>}
151
159
  */
@@ -155,7 +163,7 @@ export function fetchCapabilities(prNumber, opts = {}) {
155
163
  }
156
164
 
157
165
  /**
158
- * @param {number} prNumber
166
+ * @param {ReviewKey} prNumber
159
167
  * @param {import('./contract-types.js').PostCommentInput} input
160
168
  * @param {{ fetchImpl?: typeof fetch }} [opts]
161
169
  * @returns {Promise<import('./contract-types.js').PostCommentResponse>}
@@ -169,7 +177,61 @@ export function postComment(prNumber, input, opts = {}) {
169
177
  }
170
178
 
171
179
  /**
172
- * @param {number} prNumber
180
+ * Adds one comment to the pending review. Nothing reaches the forge: it is kept in the local
181
+ * state until the review is submitted.
182
+ * @param {ReviewKey} prNumber
183
+ * @param {import('./contract-types.js').AddPendingInput} input
184
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
185
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
186
+ */
187
+ export function addPending(prNumber, input, opts = {}) {
188
+ return fetchJson(`/api/prs/${prNumber}/pending`, {
189
+ method: 'POST',
190
+ body: input,
191
+ fetchImpl: opts.fetchImpl,
192
+ })
193
+ }
194
+
195
+ /**
196
+ * @param {ReviewKey} prNumber
197
+ * @param {string} id
198
+ * @param {string} body
199
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
200
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
201
+ */
202
+ export function editPending(prNumber, id, body, opts = {}) {
203
+ return fetchJson(`/api/prs/${prNumber}/pending/${encodeURIComponent(id)}`, {
204
+ method: 'PATCH',
205
+ body: { body },
206
+ fetchImpl: opts.fetchImpl,
207
+ })
208
+ }
209
+
210
+ /**
211
+ * @param {ReviewKey} prNumber
212
+ * @param {string} id
213
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
214
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
215
+ */
216
+ export function deletePending(prNumber, id, opts = {}) {
217
+ return fetchJson(`/api/prs/${prNumber}/pending/${encodeURIComponent(id)}`, {
218
+ method: 'DELETE',
219
+ fetchImpl: opts.fetchImpl,
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Throws the whole pending review away.
225
+ * @param {ReviewKey} prNumber
226
+ * @param {{ fetchImpl?: typeof fetch }} [opts]
227
+ * @returns {Promise<import('./contract-types.js').StateResponse>}
228
+ */
229
+ export function discardPending(prNumber, opts = {}) {
230
+ return fetchJson(`/api/prs/${prNumber}/pending`, { method: 'DELETE', fetchImpl: opts.fetchImpl })
231
+ }
232
+
233
+ /**
234
+ * @param {ReviewKey} prNumber
173
235
  * @param {{ fetchImpl?: typeof fetch }} [opts]
174
236
  * @returns {Promise<import('./contract-types.js').ReviewBodyResponse>}
175
237
  */
@@ -178,8 +240,8 @@ export function fetchReviewBody(prNumber, opts = {}) {
178
240
  }
179
241
 
180
242
  /**
181
- * @param {number} prNumber
182
- * @param {{ event: 'APPROVE' | 'REQUEST_CHANGES', body?: string, headSha?: string }} input
243
+ * @param {ReviewKey} prNumber
244
+ * @param {{ event: import('./contract-types.js').ReviewEvent, body?: string, headSha?: string, includePending?: boolean }} input
183
245
  * @param {{ fetchImpl?: typeof fetch }} [opts]
184
246
  * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
185
247
  */
@@ -210,7 +272,7 @@ export const POLL_SLOW_INTERVAL_MS = 15_000
210
272
  * After five minutes of waiting the gap grows to 15 s, because a generation that has not finished
211
273
  * by then takes minutes more. A failed poll is reported and polling goes on; the page never
212
274
  * blocks on it.
213
- * @param {number} prNumber
275
+ * @param {ReviewKey} prNumber
214
276
  * @param {PollOptions} [opts]
215
277
  * @returns {{ stop: () => void }}
216
278
  */
@@ -230,7 +292,7 @@ export function pollBundle(prNumber, opts = {}) {
230
292
  /** @type {PrBundle} */
231
293
  let bundle
232
294
  try {
233
- bundle = await fetchBundle(prNumber, { fetchImpl: opts.fetchImpl })
295
+ bundle = await fetchBundle(prNumber, { poll: true, fetchImpl: opts.fetchImpl })
234
296
  } catch (err) {
235
297
  if (!stopped) {
236
298
  opts.onError?.(err)
@@ -306,7 +368,7 @@ export function uploadForm(url, form, opts = {}) {
306
368
  }
307
369
 
308
370
  /**
309
- * @param {number} prNumber
371
+ * @param {ReviewKey} prNumber
310
372
  * @param {File} file
311
373
  * @param {{ force?: boolean, onProgress?: (fraction: number) => void, xhrImpl?: () => XMLHttpRequest }} [opts]
312
374
  * @returns {Promise<import('./contract-types.js').ImportResult>}
@@ -321,7 +383,7 @@ export function importCanvas(prNumber, file, opts = {}) {
321
383
  }
322
384
 
323
385
  /**
324
- * @param {number} prNumber
386
+ * @param {ReviewKey} prNumber
325
387
  * @param {{ fetchImpl?: typeof fetch }} [opts]
326
388
  * @returns {Promise<import('./contract-types.js').SharedCanvasFetchResponse>}
327
389
  */
@@ -331,7 +393,7 @@ export function fetchSharedCanvas(prNumber, opts = {}) {
331
393
 
332
394
  /**
333
395
  * The zip as a blob plus the name the server chose, ready to hand to the browser.
334
- * @param {number} prNumber
396
+ * @param {ReviewKey} prNumber
335
397
  * @param {{ headSha?: string, fetchImpl?: typeof fetch }} [opts]
336
398
  * @returns {Promise<{ blob: Blob, filename: string }>}
337
399
  */
@@ -378,7 +440,7 @@ export function saveAppearance(input, opts = {}) {
378
440
  /* ---- AI Chat and its settings ---- */
379
441
 
380
442
  /**
381
- * @param {number} prNumber
443
+ * @param {ReviewKey} prNumber
382
444
  * @param {{ fetchImpl?: typeof fetch }} [opts]
383
445
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
384
446
  */
@@ -387,7 +449,7 @@ export function fetchThreads(prNumber, opts = {}) {
387
449
  }
388
450
 
389
451
  /**
390
- * @param {number} prNumber
452
+ * @param {ReviewKey} prNumber
391
453
  * @param {{ fetchImpl?: typeof fetch }} [opts]
392
454
  * @returns {Promise<import('./contract-types.js').ChatThreadsResponse>}
393
455
  */
@@ -400,7 +462,7 @@ export function createThread(prNumber, opts = {}) {
400
462
  }
401
463
 
402
464
  /**
403
- * @param {number} prNumber
465
+ * @param {ReviewKey} prNumber
404
466
  * @param {string} name
405
467
  * @param {{ fetchImpl?: typeof fetch }} [opts]
406
468
  * @returns {Promise<import('./contract-types.js').ChatHistoryResponse>}
@@ -412,7 +474,7 @@ export function fetchThreadHistory(prNumber, name, opts = {}) {
412
474
  }
413
475
 
414
476
  /**
415
- * @param {number} prNumber
477
+ * @param {ReviewKey} prNumber
416
478
  * @param {{ fetchImpl?: typeof fetch }} [opts]
417
479
  * @returns {Promise<{ cancelled: boolean }>}
418
480
  */
@@ -501,7 +563,7 @@ export function readSseFrames(buffer, chunk) {
501
563
  /**
502
564
  * Sends one chat message and calls `onEvent` for every frame until the turn ends. Rejects with
503
565
  * an ApiError when the server refuses the message (a busy chat, a context it cannot resolve).
504
- * @param {number} prNumber
566
+ * @param {ReviewKey} prNumber
505
567
  * @param {{ message: string, context: import('./chat-context.js').ChatContext, thread?: string }} input
506
568
  * @param {{
507
569
  * onEvent: (event: { event: string, data: unknown }) => void,