@vintasoftware/pr-review-canvas 0.2.0 → 0.4.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 (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import type { CanvasRelation } from '../contract/api.js'
2
+ import type { CarriedOverInfo } from '../contract/api.js'
3
3
  import {
4
4
  type CanvasIndex,
5
5
  CanvasIndexSchema,
@@ -7,13 +7,43 @@ import {
7
7
  CanvasManifestSchema,
8
8
  } from '../contract/canvas-manifest.js'
9
9
  import { type ReviewArtifact, ReviewArtifactSchema } from '../contract/review-artifact.js'
10
+ import { isLocalKey, type LocalKey, type ReviewKey } from '../contract/review-key.js'
10
11
  import type { Git } from '../git/git.js'
11
12
  import { readJson, readText, writeJsonAtomic } from './atomic-json.js'
12
13
 
14
+ /** A canvas of a commit the head was built on, and how many commits the head gained since. */
15
+ export interface AncestorCanvas {
16
+ status: 'stale'
17
+ headSha: string
18
+ relation: 'ancestor'
19
+ commitsBehind: number
20
+ }
21
+
13
22
  export type CanvasLookup =
14
- | { status: 'ready'; headSha: string }
15
- | { status: 'stale'; headSha: string; relation: CanvasRelation; commitsBehind?: number }
16
- | { status: 'missing' }
23
+ /** `headSha` is the canvas's own commit: the head, or another commit with an identical diff. */
24
+ { status: 'ready'; headSha: string; carriedOver?: CarriedOverInfo } | StaleCanvas | { status: 'missing' }
25
+
26
+ /** A canvas of another commit: one the head was built on, or one from a line the head no longer contains. */
27
+ export type StaleCanvas = AncestorCanvas | { status: 'stale'; headSha: string; relation: 'unrelated' }
28
+
29
+ /** One canvas as `index.json` lists it. */
30
+ export type CanvasEntry = CanvasIndex['canvases'][string]
31
+
32
+ /**
33
+ * Whether a canvas is one of this target's. A canvas belongs to a pull request when it was
34
+ * generated for it or before it existed; to a local review when it belongs to no pull request. A
35
+ * snapshot of uncommitted work sits on no branch, so only the `uncommitted` review claims it.
36
+ *
37
+ * Every consumer asks through here: the lookups that pick the canvas to show, and the routes that
38
+ * accept a canvas a mark was made on. Two answers to this question is how marks end up keyed to a
39
+ * canvas their review will never show.
40
+ */
41
+ export function canvasBelongsTo(entry: CanvasEntry, key: ReviewKey): boolean {
42
+ if (isLocalKey(key)) {
43
+ return entry.prNumber === undefined && (key === 'uncommitted' || entry.worktree !== true)
44
+ }
45
+ return entry.worktree !== true && (entry.prNumber === undefined || entry.prNumber === key)
46
+ }
17
47
 
18
48
  export interface CanvasStore {
19
49
  readonly root: string
@@ -24,12 +54,23 @@ export interface CanvasStore {
24
54
  readArtifact(headSha: string): Promise<ReviewArtifact | null>
25
55
  readManifest(headSha: string): Promise<CanvasManifest | null>
26
56
  /** Writes review.json + manifest.json atomically and records the canvas in index.json. */
27
- write(headSha: string, artifact: ReviewArtifact, manifest: CanvasManifest, prNumber?: number): Promise<void>
57
+ write(
58
+ headSha: string,
59
+ artifact: ReviewArtifact,
60
+ manifest: CanvasManifest,
61
+ prNumber?: number,
62
+ flags?: { worktree?: boolean }
63
+ ): Promise<void>
28
64
  /**
29
65
  * The best canvas for this PR: the one written for its current head, else the newest one the
30
66
  * head was built on top of, else one from a branch the head no longer contains.
31
67
  */
32
68
  findForPr(prNumber: number, currentHeadSha: string): Promise<CanvasLookup>
69
+ /**
70
+ * The best canvas for a local review: the one for this head, else the newest canvas that belongs
71
+ * to no pull request. A snapshot of uncommitted work counts only for the `uncommitted` review.
72
+ */
73
+ findForLocal(key: LocalKey, currentHeadSha: string): Promise<CanvasLookup>
33
74
  /** Records the PR number on a canvas that was exported before the pull request existed. */
34
75
  attachPrNumber(headSha: string, prNumber: number): Promise<void>
35
76
  }
@@ -44,7 +85,45 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
44
85
  }
45
86
  return path.join(repoRoot, 'canvases', headSha)
46
87
  }
47
- const readIndex = async (): Promise<CanvasIndex> => (await readJson(indexFile, CanvasIndexSchema)) ?? { canvases: {} }
88
+ const readIndex = async (): Promise<CanvasIndex> =>
89
+ (await readJson(indexFile, CanvasIndexSchema)) ?? { canvases: {} }
90
+
91
+ /**
92
+ * The canvas for this head, or the closest one `accept` allows. Commits the head was built on
93
+ * come first, newest wins inside a relation, and a canvas off a discarded branch is the last
94
+ * resort.
95
+ */
96
+ const rank = async (
97
+ currentHeadSha: string,
98
+ accept: (entry: CanvasEntry) => boolean
99
+ ): Promise<CanvasLookup> => {
100
+ const index = await readIndex()
101
+ if (index.canvases[currentHeadSha] !== undefined) {
102
+ return { status: 'ready', headSha: currentHeadSha }
103
+ }
104
+ const candidates = Object.entries(index.canvases).filter(
105
+ ([sha, entry]) => sha !== currentHeadSha && accept(entry)
106
+ )
107
+ const ranked: Array<{ generatedAt: string; found: StaleCanvas }> = []
108
+ for (const [sha, entry] of candidates) {
109
+ const found: StaleCanvas = (await git.isAncestor(sha, currentHeadSha))
110
+ ? {
111
+ status: 'stale',
112
+ headSha: sha,
113
+ relation: 'ancestor',
114
+ commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
115
+ }
116
+ : { status: 'stale', headSha: sha, relation: 'unrelated' }
117
+ ranked.push({ generatedAt: entry.generatedAt, found })
118
+ }
119
+ ranked.sort((a, b) => {
120
+ if (a.found.relation !== b.found.relation) {
121
+ return a.found.relation === 'ancestor' ? -1 : 1
122
+ }
123
+ return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
124
+ })
125
+ return ranked[0]?.found ?? { status: 'missing' }
126
+ }
48
127
 
49
128
  return {
50
129
  root: repoRoot,
@@ -53,12 +132,15 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
53
132
  exists: async headSha => (await readText(path.join(canvasDir(headSha), 'review.json'))) !== null,
54
133
  readArtifact: headSha => readJson(path.join(canvasDir(headSha), 'review.json'), ReviewArtifactSchema),
55
134
  readManifest: headSha => readJson(path.join(canvasDir(headSha), 'manifest.json'), CanvasManifestSchema),
56
- write: async (headSha, artifact, manifest, prNumber) => {
135
+ write: async (headSha, artifact, manifest, prNumber, flags) => {
57
136
  const dir = canvasDir(headSha)
58
137
  await writeJsonAtomic(path.join(dir, 'review.json'), artifact)
59
138
  await writeJsonAtomic(path.join(dir, 'manifest.json'), manifest)
60
139
  const index = await readIndex()
61
- const entry: CanvasIndex['canvases'][string] = { generatedAt: artifact.generatedAt, source: artifact.source }
140
+ const entry: CanvasEntry = {
141
+ generatedAt: artifact.generatedAt,
142
+ source: artifact.source,
143
+ }
62
144
  const number = prNumber ?? manifest.prNumber
63
145
  if (number !== undefined) {
64
146
  entry.prNumber = number
@@ -66,47 +148,14 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
66
148
  if (artifact.importedAt !== undefined) {
67
149
  entry.importedAt = artifact.importedAt
68
150
  }
151
+ if (flags?.worktree === true) {
152
+ entry.worktree = true
153
+ }
69
154
  index.canvases[headSha] = entry
70
155
  await writeJsonAtomic(indexFile, index)
71
156
  },
72
- findForPr: async (prNumber, currentHeadSha) => {
73
- const index = await readIndex()
74
- if (index.canvases[currentHeadSha] !== undefined) {
75
- return { status: 'ready', headSha: currentHeadSha }
76
- }
77
- // A canvas belongs to this PR when it was exported for it, or before the PR existed.
78
- const candidates = Object.entries(index.canvases).filter(
79
- ([sha, entry]) => sha !== currentHeadSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
80
- )
81
- const ranked: Array<{ headSha: string; generatedAt: string; relation: CanvasRelation; commitsBehind?: number }> =
82
- []
83
- for (const [sha, entry] of candidates) {
84
- if (await git.isAncestor(sha, currentHeadSha)) {
85
- ranked.push({
86
- headSha: sha,
87
- generatedAt: entry.generatedAt,
88
- relation: 'ancestor',
89
- commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
90
- })
91
- } else {
92
- ranked.push({ headSha: sha, generatedAt: entry.generatedAt, relation: 'unrelated' })
93
- }
94
- }
95
- // Commits the head was built on come first; a force-pushed-away canvas is the last resort.
96
- ranked.sort((a, b) => {
97
- if (a.relation !== b.relation) {
98
- return a.relation === 'ancestor' ? -1 : 1
99
- }
100
- return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
101
- })
102
- const best = ranked[0]
103
- if (best === undefined) {
104
- return { status: 'missing' }
105
- }
106
- return best.commitsBehind === undefined
107
- ? { status: 'stale', headSha: best.headSha, relation: best.relation }
108
- : { status: 'stale', headSha: best.headSha, relation: best.relation, commitsBehind: best.commitsBehind }
109
- },
157
+ findForPr: (prNumber, currentHeadSha) => rank(currentHeadSha, entry => canvasBelongsTo(entry, prNumber)),
158
+ findForLocal: (key, currentHeadSha) => rank(currentHeadSha, entry => canvasBelongsTo(entry, key)),
110
159
  attachPrNumber: async (headSha, prNumber) => {
111
160
  const index = await readIndex()
112
161
  const entry = index.canvases[headSha]
@@ -25,5 +25,6 @@ export async function ensureDataDir(dataDir: string): Promise<void> {
25
25
  }
26
26
 
27
27
  export function repoDir(dataDir: string, repo: Repo): string {
28
- return path.join(dataDir, 'repos', `${repo.owner}__${repo.name}`)
28
+ const owner = repo.owner.replaceAll('/', '__')
29
+ return path.join(dataDir, 'repos', `${owner}__${repo.name}`)
29
30
  }
@@ -24,6 +24,12 @@ export interface DerivedStore {
24
24
  /** Reads `derived/` when it matches the commits, otherwise rebuilds it from local git. */
25
25
  ensure(headSha: string, mergeBaseSha: string): Promise<Derived>
26
26
  read(headSha: string): Promise<Derived | null>
27
+ /**
28
+ * The diffs built from the clone when the merge base is known and both commits are local, so a
29
+ * base that advanced under the head is seen; else the stored copy, which is all an imported
30
+ * canvas has.
31
+ */
32
+ readOrBuild(headSha: string, mergeBaseSha: string | undefined): Promise<Derived | null>
27
33
  /** Lines `from..to` (1-based, inclusive) of a materialized file, or null when not materialized. */
28
34
  readLines(
29
35
  headSha: string,
@@ -47,32 +53,44 @@ export function createDerivedStore(canvases: CanvasStore, git: Git, now: () => D
47
53
  return { files, patches }
48
54
  }
49
55
 
56
+ const derivable = async (headSha: string, mergeBaseSha: string): Promise<boolean> =>
57
+ (await git.commitExists(headSha)) && (await git.commitExists(mergeBaseSha))
58
+
59
+ const ensure = async (headSha: string, mergeBaseSha: string): Promise<Derived> => {
60
+ const dir = derivedDir(headSha)
61
+ const meta = await readJson(path.join(dir, 'meta.json'), DerivedMetaSchema)
62
+ if (meta !== null && meta.mergeBaseSha === mergeBaseSha) {
63
+ const cached = await read(headSha)
64
+ if (cached !== null) {
65
+ return cached
66
+ }
67
+ }
68
+ const collected = await collectDiffs(git, mergeBaseSha, headSha)
69
+ const files = collected.map(toFileEntry)
70
+ const patches = toPatchMap(collected)
71
+ await writeJsonAtomic(path.join(dir, 'files.json'), files)
72
+ await writeJsonAtomic(path.join(dir, 'patches.json'), patches)
73
+ for (const f of collected) {
74
+ await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
75
+ }
76
+ await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
77
+ await writeJsonAtomic(path.join(dir, 'meta.json'), {
78
+ headSha,
79
+ mergeBaseSha,
80
+ builtAt: now().toISOString(),
81
+ })
82
+ return { files, patches }
83
+ }
84
+
50
85
  return {
51
86
  derivedDir,
52
- derivable: async (headSha, mergeBaseSha) =>
53
- (await git.commitExists(headSha)) && (await git.commitExists(mergeBaseSha)),
54
- ensure: async (headSha, mergeBaseSha) => {
55
- const dir = derivedDir(headSha)
56
- const meta = await readJson(path.join(dir, 'meta.json'), DerivedMetaSchema)
57
- if (meta !== null && meta.mergeBaseSha === mergeBaseSha) {
58
- const cached = await read(headSha)
59
- if (cached !== null) {
60
- return cached
61
- }
62
- }
63
- const collected = await collectDiffs(git, mergeBaseSha, headSha)
64
- const files = collected.map(toFileEntry)
65
- const patches = toPatchMap(collected)
66
- await writeJsonAtomic(path.join(dir, 'files.json'), files)
67
- await writeJsonAtomic(path.join(dir, 'patches.json'), patches)
68
- for (const f of collected) {
69
- await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
70
- }
71
- await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
72
- await writeJsonAtomic(path.join(dir, 'meta.json'), { headSha, mergeBaseSha, builtAt: now().toISOString() })
73
- return { files, patches }
74
- },
87
+ derivable,
88
+ ensure,
75
89
  read,
90
+ readOrBuild: async (headSha, mergeBaseSha) =>
91
+ mergeBaseSha !== undefined && (await derivable(headSha, mergeBaseSha))
92
+ ? ensure(headSha, mergeBaseSha)
93
+ : read(headSha),
76
94
  readLines: async (headSha, side, filePath, from, to) => {
77
95
  const root = path.join(derivedDir(headSha), side)
78
96
  const full = path.resolve(root, filePath)
@@ -2,44 +2,54 @@ import { readdir, stat } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { type CommentsPayload, CommentsPayloadSchema } from '../contract/comments.js'
4
4
  import { type DiscoveryCache, DiscoveryCacheSchema } from '../contract/discovery.js'
5
+ import { type LocalPrepareTarget, LocalPrepareTargetSchema } from '../contract/generation-context.js'
6
+ import { isLocalKey, keyToString, type LocalKey, type ReviewKey } from '../contract/review-key.js'
5
7
  import { type Pr, PrSchema } from '../contract/review-artifact.js'
6
8
  import { readJson, readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
7
9
 
8
- /** PR-keyed files: the cached PR meta and the last comments payload. */
10
+ /** Target-keyed files: the cached PR meta and the last comments payload. */
9
11
  export interface PrStore {
10
- prDir(number: number): string
11
- readPr(number: number): Promise<Pr | null>
12
- writePr(pr: Pr): Promise<void>
12
+ prDir(key: ReviewKey): string
13
+ readPr(key: ReviewKey): Promise<Pr | null>
14
+ /** The key is passed in, because the local review's meta carries no number of its own. */
15
+ writePr(key: ReviewKey, pr: Pr): Promise<void>
13
16
  readComments(number: number): Promise<CommentsPayload | null>
14
17
  writeComments(number: number, comments: CommentsPayload): Promise<void>
15
18
  /** The last attachment scan, so an unchanged PR is not scanned again. */
16
19
  readDiscovery(number: number): Promise<DiscoveryCache | null>
17
20
  writeDiscovery(number: number, discovery: DiscoveryCache): Promise<void>
18
- /** PR numbers with a cached pr.json, newest first by file mtime. */
21
+ /** PR numbers with a cached pr.json, newest first by file mtime. Local reviews are not ones. */
19
22
  listRecent(limit: number): Promise<Array<{ number: number; title: string; updatedAt: string }>>
23
+ /** What this local review was last prepared for, so the page can resolve the same head again. */
24
+ readLocalTarget(key: LocalKey): Promise<LocalPrepareTarget | null>
25
+ writeLocalTarget(key: LocalKey, target: LocalPrepareTarget): Promise<void>
20
26
  }
21
27
 
22
28
  export function createPrStore(repoRoot: string): PrStore {
23
- const prDir = (number: number): string => {
24
- if (!Number.isInteger(number) || number <= 0) {
25
- throw new Error(`not a pull request number: ${number}`)
29
+ const prDir = (key: ReviewKey): string => {
30
+ if (!isLocalKey(key) && (!Number.isInteger(key) || key <= 0)) {
31
+ throw new Error(`not a pull request number: ${String(key)}`)
26
32
  }
27
- return path.join(repoRoot, 'prs', String(number))
33
+ return path.join(repoRoot, 'prs', keyToString(key))
28
34
  }
29
35
  return {
30
36
  prDir,
31
- readPr: number => readJson(path.join(prDir(number), 'pr.json'), PrSchema),
32
- writePr: async pr => {
33
- if (pr.number === null) {
34
- throw new Error('cannot cache a pre-PR change set under prs/')
37
+ readPr: key => readJson(path.join(prDir(key), 'pr.json'), PrSchema),
38
+ writePr: async (key, pr) => {
39
+ if (!isLocalKey(key) && pr.number !== key) {
40
+ throw new Error(`pull request ${String(key)} cannot hold the meta of ${String(pr.number)}`)
35
41
  }
36
- await writeJsonAtomic(path.join(prDir(pr.number), 'pr.json'), pr)
42
+ await writeJsonAtomic(path.join(prDir(key), 'pr.json'), pr)
37
43
  },
44
+ readLocalTarget: key =>
45
+ readJsonOrDefault(path.join(prDir(key), 'target.json'), LocalPrepareTargetSchema, () => null),
46
+ writeLocalTarget: (key, target) => writeJsonAtomic(path.join(prDir(key), 'target.json'), target),
38
47
  readComments: number => readJson(path.join(prDir(number), 'comments.json'), CommentsPayloadSchema),
39
48
  writeComments: (number, comments) => writeJsonAtomic(path.join(prDir(number), 'comments.json'), comments),
40
49
  readDiscovery: number =>
41
50
  readJsonOrDefault(path.join(prDir(number), 'discovery.json'), DiscoveryCacheSchema, () => null),
42
- writeDiscovery: (number, discovery) => writeJsonAtomic(path.join(prDir(number), 'discovery.json'), discovery),
51
+ writeDiscovery: (number, discovery) =>
52
+ writeJsonAtomic(path.join(prDir(number), 'discovery.json'), discovery),
43
53
  listRecent: async limit => {
44
54
  let names: string[]
45
55
  try {
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import type { ReviewKey } from '../contract/review-key.js'
2
3
  import { emptyState, type PrState, PrStateSchema } from '../contract/state.js'
3
4
  import { readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
4
5
  import type { PrStore } from './pr-store.js'
@@ -9,23 +10,24 @@ export interface PostedEntry {
9
10
  pointFingerprint?: string
10
11
  }
11
12
 
12
- /** Per-PR local state: reviewed cards, dismissed points, hidden threads, posted comments. */
13
+ /** Per-target local state: reviewed cards, dismissed points, hidden threads, posted comments. */
13
14
  export interface StateStore {
14
15
  /** Defaults when the file is missing or does not match the schema (an older tool version wrote it). */
15
- read(number: number): Promise<PrState>
16
+ read(key: ReviewKey): Promise<PrState>
16
17
  /**
17
- * Read, change, write. Calls for the same PR run one after another, so two requests that
18
+ * Read, change, write. Calls for the same target run one after another, so two requests that
18
19
  * arrive together both land instead of one overwriting the other.
19
20
  */
20
- update(number: number, mutate: (state: PrState) => PrState): Promise<PrState>
21
+ update(key: ReviewKey, mutate: (state: PrState) => PrState): Promise<PrState>
21
22
  /**
22
- * Marks one layer or file. `headSha` is the commit the page was showing: when it differs from
23
- * the one the marks describe, the old marks are dropped, because they were about other code.
23
+ * Marks one layer or file. `canvasSha` is the commit of the canvas the page was showing: when it
24
+ * differs from the one the marks describe, the old marks are dropped, because they were about
25
+ * other code.
24
26
  */
25
- setReviewed(number: number, id: string, reviewed: boolean, headSha?: string): Promise<PrState>
26
- setDismissed(number: number, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
27
- setThreadHidden(number: number, rootCommentId: number, hidden: boolean): Promise<PrState>
28
- addPosted(number: number, entry: PostedEntry): Promise<PrState>
27
+ setReviewed(key: ReviewKey, id: string, reviewed: boolean, canvasSha?: string): Promise<PrState>
28
+ setDismissed(key: ReviewKey, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
29
+ setThreadHidden(key: ReviewKey, rootCommentId: number, hidden: boolean): Promise<PrState>
30
+ addPosted(key: ReviewKey, entry: PostedEntry): Promise<PrState>
29
31
  }
30
32
 
31
33
  /** `layer:<id>` or `layer:<id>/file:<key>`, with the ids and keys the artifact uses. */
@@ -42,25 +44,25 @@ function layerOf(id: string): string {
42
44
  }
43
45
 
44
46
  export function createStateStore(prs: PrStore, now: () => Date): StateStore {
45
- const file = (number: number): string => path.join(prs.prDir(number), 'state.json')
46
- const read = (number: number): Promise<PrState> =>
47
- readJsonOrDefault(file(number), PrStateSchema, () => emptyState(now().toISOString()))
47
+ const file = (key: ReviewKey): string => path.join(prs.prDir(key), 'state.json')
48
+ const read = (key: ReviewKey): Promise<PrState> =>
49
+ readJsonOrDefault(file(key), PrStateSchema, () => emptyState(now().toISOString()))
48
50
 
49
- /** One chain per PR number; each update waits for the one before it. */
50
- const chains = new Map<number, Promise<unknown>>()
51
+ /** One chain per target; each update waits for the one before it. */
52
+ const chains = new Map<ReviewKey, Promise<unknown>>()
51
53
 
52
- const update = (number: number, mutate: (state: PrState) => PrState): Promise<PrState> => {
54
+ const update = (key: ReviewKey, mutate: (state: PrState) => PrState): Promise<PrState> => {
53
55
  const run = async (): Promise<PrState> => {
54
- const current = await read(number)
56
+ const current = await read(key)
55
57
  // Every write counts up, so the page can tell which of two answers was written later.
56
58
  const next = { ...mutate(current), rev: (current.rev ?? 0) + 1, updatedAt: now().toISOString() }
57
- await writeJsonAtomic(file(number), next)
59
+ await writeJsonAtomic(file(key), next)
58
60
  return next
59
61
  }
60
- const chained = (chains.get(number) ?? Promise.resolve()).then(run, run)
62
+ const chained = (chains.get(key) ?? Promise.resolve()).then(run, run)
61
63
  // The chain only orders the calls, so a failed update never blocks the next one.
62
64
  chains.set(
63
- number,
65
+ key,
64
66
  chained.catch(() => undefined)
65
67
  )
66
68
  return chained
@@ -69,38 +71,41 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
69
71
  return {
70
72
  read,
71
73
  update,
72
- setReviewed: (number, id, reviewed, headSha) =>
73
- update(number, state => {
74
- const sameHead =
75
- headSha === undefined || state.reviewedHeadSha === undefined || state.reviewedHeadSha === headSha
76
- const next = sameHead ? { ...state.reviewed } : {}
74
+ setReviewed: (key, id, reviewed, canvasSha) =>
75
+ update(key, state => {
76
+ const sameCanvas =
77
+ canvasSha === undefined ||
78
+ state.reviewedCanvasSha === undefined ||
79
+ state.reviewedCanvasSha === canvasSha
80
+ const next = sameCanvas ? { ...state.reviewed } : {}
77
81
  if (reviewed) {
78
82
  next[id] = true
79
83
  } else {
80
- for (const key of Object.keys(next)) {
84
+ for (const marked of Object.keys(next)) {
81
85
  // Reopening a layer reopens its files, and reopening a file reopens its layer:
82
86
  // both readings of "reviewed" have to agree.
83
- if (key === id || key.startsWith(`${id}/`) || key === layerOf(id)) {
84
- delete next[key]
87
+ if (marked === id || marked.startsWith(`${id}/`) || marked === layerOf(id)) {
88
+ delete next[marked]
85
89
  }
86
90
  }
87
91
  }
88
- return headSha === undefined
92
+ return canvasSha === undefined
89
93
  ? { ...state, reviewed: next }
90
- : { ...state, reviewed: next, reviewedHeadSha: headSha }
94
+ : { ...state, reviewed: next, reviewedCanvasSha: canvasSha }
91
95
  }),
92
- setDismissed: (number, fingerprint, dismissed, reason) =>
93
- update(number, state => {
96
+ setDismissed: (key, fingerprint, dismissed, reason) =>
97
+ update(key, state => {
94
98
  const next = { ...state.dismissed }
95
99
  if (dismissed) {
96
- next[fingerprint] = reason === undefined ? { at: now().toISOString() } : { at: now().toISOString(), reason }
100
+ next[fingerprint] =
101
+ reason === undefined ? { at: now().toISOString() } : { at: now().toISOString(), reason }
97
102
  } else {
98
103
  delete next[fingerprint]
99
104
  }
100
105
  return { ...state, dismissed: next }
101
106
  }),
102
- setThreadHidden: (number, rootCommentId, hidden) =>
103
- update(number, state => {
107
+ setThreadHidden: (key, rootCommentId, hidden) =>
108
+ update(key, state => {
104
109
  const next = { ...state.hiddenThreads }
105
110
  if (hidden) {
106
111
  next[String(rootCommentId)] = { at: now().toISOString() }
@@ -109,8 +114,8 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
109
114
  }
110
115
  return { ...state, hiddenThreads: next }
111
116
  }),
112
- addPosted: (number, entry) =>
113
- update(number, state => {
117
+ addPosted: (key, entry) =>
118
+ update(key, state => {
114
119
  if (state.posted.some(p => p.commentId === entry.commentId)) {
115
120
  return state
116
121
  }
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" shape-rendering="crispEdges">
2
+ <title>PR review canvas</title>
3
+ <path fill="#7661a8" d="M12 7h31v35H12z"/>
4
+ <path fill="#aa96d2" d="M39 9h2v24h-2z"/>
5
+ <path fill="#292b3d" d="M3 3h35v41H3z"/>
6
+ <path fill="#fff0cf" d="M5 12h31v30H5z"/>
7
+ <path fill="#45465c" d="M5 5h31v5H5z"/>
8
+ <path fill="#ffe08a" d="M22 8h3v1h-3z"/>
9
+ <path fill="#ffffff" d="M27 6h3v3h-3zm1 1v1h1V7z" fill-rule="evenodd"/>
10
+ <path fill="#ffb3ab" d="M32 6h1v1h1V6h1v1h-1v1h1v1h-1V8h-1v1h-1V8h1V7h-1z"/>
11
+ <path fill="#b44a45" d="M9 17h9v3H9z"/>
12
+ <path fill="#e8ddc4" d="M20 15h1v19h-1z"/>
13
+ <path fill="#827b70" d="M23 17h10v2H23zM23 26h10v2H23zM9 36h16v2H9z"/>
14
+ <path fill="#437d68" d="M12 23h3v3h3v3h-3v3h-3v-3H9v-3h3z"/>
15
+ <g transform="scale(0.5)" shape-rendering="geometricPrecision">
16
+ <path fill="#292b3d" d="M56 68H58V66H60V64H62V62H68V64H70V66H72V68H76V64H78V60H80V56H82V52H84V50H90V52H92V54H94V56H96V62H94V66H92V70H90V74H88V78H86V82H84V86H82V90H80V92H72V90H70V88H68V86H66V84H64V82H62V80H60V78H58V76H56z"/>
17
+ <path fill="#8bc79a" d="M58 70H60V68H62V66H64V64H66V66H68V68H70V70H72V72H74V74H76V70H78V66H80V62H82V58H84V54H86V52H88V54H90V56H92V58H94V60H92V64H90V68H88V72H86V76H84V80H82V84H80V88H78V90H74V88H72V86H70V84H68V82H66V80H64V78H62V76H60V74H58z"/>
18
+ </g>
19
+ </svg>