@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
@@ -0,0 +1,79 @@
1
+ // A canvas carried over to a later head: the head's diff is identical to the diff the canvas was
2
+ // generated from, so the canvas stands for it. The one place that reads the rule.
3
+ import type { CarriedOverInfo } from '../contract/api.js'
4
+ import type { Pr } from '../contract/review-artifact.js'
5
+ import type { AppContext } from '../server/context.js'
6
+ import type { CanvasLookup } from '../store/canvas-store.js'
7
+ import type { Derived } from '../store/derived-store.js'
8
+
9
+ /** A commit with the merge base its diff runs from; `Pr`, a prepared context, and a canvas manifest all provide it. */
10
+ export interface DiffedCommit {
11
+ headSha: string
12
+ mergeBaseSha: string
13
+ }
14
+
15
+ /**
16
+ * True when `commit` stands for the head: it is the head, or the head's diff against its merge
17
+ * base is identical to `commit`'s diff against its own, file by file and byte for byte. Layers,
18
+ * hunk ids, folds, and attention points all assume the diff on screen is the one the canvas was
19
+ * generated from, and identity is what guarantees that; a base merge that only moves a hunk down
20
+ * already breaks it. How the head reached that diff does not matter. A diff missing on this
21
+ * machine, and a change set that is empty on both sides, keep the strict reading. Off when the
22
+ * project marks the canvas outdated on any commit.
23
+ */
24
+ export async function standsForHead(
25
+ ctx: AppContext,
26
+ pr: DiffedCommit,
27
+ commit: DiffedCommit
28
+ ): Promise<boolean> {
29
+ if (commit.headSha === pr.headSha) {
30
+ return true
31
+ }
32
+ if (!ctx.projectConfig.config.canvas.keepForIdenticalDiff) {
33
+ return false
34
+ }
35
+ const [older, head] = await Promise.all([
36
+ ctx.derived.readOrBuild(commit.headSha, commit.mergeBaseSha),
37
+ ctx.derived.readOrBuild(pr.headSha, pr.mergeBaseSha),
38
+ ])
39
+ if (older === null || head === null) {
40
+ return false
41
+ }
42
+ // Two empty diffs are equal by having nothing to compare, which is no evidence that the canvas
43
+ // explains the head. An empty change set has nothing to review either way, so the strict
44
+ // reading costs the reviewer nothing here.
45
+ return Object.keys(head.patches).length > 0 && samePatches(older, head)
46
+ }
47
+
48
+ /**
49
+ * Whether two diffs change the same code: the same patch keys, each with the same patch body.
50
+ * The patches are the diff; the files array beside them only counts and flags what the patches
51
+ * already say, so comparing the patches is comparing the whole change.
52
+ */
53
+ export function samePatches(a: Derived, b: Derived): boolean {
54
+ const keys = Object.keys(a.patches)
55
+ return keys.length === Object.keys(b.patches).length && keys.every(k => a.patches[k] === b.patches[k])
56
+ }
57
+
58
+ /**
59
+ * The canvas for this pull request: the store's answer, with a canvas of another commit read as
60
+ * ready when that commit stands for the head. A canvas has a manifest naming its merge base;
61
+ * one whose manifest is gone cannot be compared.
62
+ */
63
+ export async function lookupCanvas(ctx: AppContext, number: number, pr: Pr): Promise<CanvasLookup> {
64
+ const found = await ctx.canvases.findForPr(number, pr.headSha)
65
+ if (found.status !== 'stale') {
66
+ return found
67
+ }
68
+ const manifest = await ctx.canvases.readManifest(found.headSha)
69
+ if (manifest !== null && (await standsForHead(ctx, pr, manifest))) {
70
+ const carriedOver: CarriedOverInfo = { canvasHeadSha: found.headSha, currentHeadSha: pr.headSha }
71
+ // How far the head moved, when the head was built on the canvas's commit. A head that
72
+ // reached the identical diff another way, by a rebase, is no distance from it at all.
73
+ if (found.relation === 'ancestor') {
74
+ carriedOver.commitsBehind = found.commitsBehind
75
+ }
76
+ return { status: 'ready', headSha: found.headSha, carriedOver }
77
+ }
78
+ return found
79
+ }
@@ -3,9 +3,11 @@
3
3
  import { randomBytes } from 'node:crypto'
4
4
  import { readFile, rm, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { parseGithubRemote } from '../config.js'
6
+ import { ORIGIN_HINT } from '../config.js'
7
7
  import type { Git } from '../git/git.js'
8
- import type { GitHubClient } from '../github/gh.js'
8
+ import { CLI_INFO, type HostClient } from '../host/client.js'
9
+ import { GITHUB_HOST, type Host } from '../host/host.js'
10
+ import { parseOriginRemote } from '../host/remote.js'
9
11
  import { ensureDataDir, resolveDataDir } from '../store/data-dir.js'
10
12
  import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME, SKILL_SOURCE_DIR } from './install-skill.js'
11
13
  import { skillContent } from './skill-content.js'
@@ -27,12 +29,15 @@ export interface DoctorReport {
27
29
 
28
30
  export interface DoctorDeps {
29
31
  git: Git
30
- gh: GitHubClient
32
+ /** Where `PR_REVIEW_HOST` is read from. */
33
+ env: NodeJS.ProcessEnv
34
+ /** The CLI client for the host origin names; without a usable origin, GitHub's is checked. */
35
+ client: (host: Host) => HostClient
31
36
  version: string
32
37
  acpxVersion: () => Promise<string | null>
33
38
  /** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
34
39
  dataDirOverride?: string | undefined
35
- readSkill?: (file: string) => Promise<string | null>
40
+ readSkill?: ReadSkill
36
41
  }
37
42
 
38
43
  function message(err: unknown): string {
@@ -55,57 +60,91 @@ async function checkDataDir(dir: string): Promise<DoctorCheck> {
55
60
  }
56
61
  }
57
62
 
63
+ export type ReadSkill = (file: string) => Promise<string | null>
64
+
65
+ const readSkillFile: ReadSkill = file => readFile(file, 'utf8')
66
+
67
+ /** One copy of the skill in the repository, next to how it compares with the bundled one. */
68
+ export interface SkillCopy {
69
+ kind: 'claude' | 'codex'
70
+ /** The skills directory the copy sits in, absolute. */
71
+ dir: string
72
+ /** `<dir>/pr-review-canvas`, relative to the repository. */
73
+ path: string
74
+ /** True when the copy's body or recorded hash differs from the bundled skill, or it cannot be read. */
75
+ stale: boolean
76
+ /** Why the copy could not be read, when it could not. */
77
+ error?: string
78
+ }
79
+
80
+ /**
81
+ * The copies of the skill in `.claude/skills` and `.agents/skills`. A directory without one is
82
+ * left out. Throws when the bundled skill itself cannot be read.
83
+ */
84
+ export async function findSkillCopies(
85
+ repoRoot: string,
86
+ readSkill: ReadSkill = readSkillFile
87
+ ): Promise<SkillCopy[]> {
88
+ const expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
89
+ const copies: SkillCopy[] = []
90
+ for (const [kind, skillsDir] of [
91
+ ['claude', CLAUDE_SKILLS_DIR],
92
+ ['codex', CODEX_SKILLS_DIR],
93
+ ] as const) {
94
+ const dir = path.join(repoRoot, skillsDir)
95
+ const target = path.join(dir, SKILL_NAME)
96
+ const rel = path.relative(repoRoot, target)
97
+ try {
98
+ const text = await readSkill(path.join(target, 'SKILL.md'))
99
+ if (text === null) continue
100
+ const { hash, frontmatter } = skillContent(text)
101
+ const matches = hash === expected && frontmatter.getIn(['metadata', 'body-sha256']) === expected
102
+ copies.push({ kind, dir, path: rel, stale: !matches })
103
+ } catch (err) {
104
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
105
+ copies.push({ kind, dir, path: rel, stale: true, error: message(err) })
106
+ }
107
+ }
108
+ }
109
+ return copies
110
+ }
111
+
58
112
  /** The skill the generation flow needs, in either harness's directory. */
59
113
  export async function checkSkill(
60
114
  repoRoot: string | null,
61
- readSkill: NonNullable<DoctorDeps['readSkill']> = file => readFile(file, 'utf8')
115
+ readSkill: ReadSkill = readSkillFile
62
116
  ): Promise<DoctorCheck> {
63
117
  if (repoRoot === null) {
64
118
  return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
65
119
  }
66
- const targets = [CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR].map(dir => path.join(repoRoot, dir, SKILL_NAME))
67
- const found: string[] = []
68
- const stale: string[] = []
69
- let expected: string
120
+ let copies: SkillCopy[]
70
121
  try {
71
- expected = skillContent(await readFile(path.join(SKILL_SOURCE_DIR, 'SKILL.md'), 'utf8')).hash
122
+ copies = await findSkillCopies(repoRoot, readSkill)
72
123
  } catch (err) {
73
124
  return { ok: false, detail: message(err), hint: 'reinstall the pr-review package' }
74
125
  }
75
- for (const target of targets) {
76
- try {
77
- const text = await readSkill(path.join(target, 'SKILL.md'))
78
- if (text === null) continue
79
- found.push(path.relative(repoRoot, target))
80
- const { hash, frontmatter } = skillContent(text)
81
- if (hash !== expected || frontmatter.getIn(['metadata', 'body-sha256']) !== expected) {
82
- stale.push(path.relative(repoRoot, target))
83
- }
84
- } catch (err) {
85
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
86
- stale.push(`${path.relative(repoRoot, target)}: ${message(err)}`)
87
- }
88
- }
89
- }
126
+ const stale = copies.flatMap(copy =>
127
+ copy.stale ? [copy.error === undefined ? copy.path : `${copy.path}: ${copy.error}`] : []
128
+ )
90
129
  if (stale.length > 0) {
91
130
  return {
92
131
  ok: false,
93
132
  detail: `outdated or modified skill: ${stale.join(', ')}`,
94
- hint: 'run `pr-review install-skill`',
133
+ hint: 'run `pr-review upgrade` or `pr-review install-skill`',
95
134
  }
96
135
  }
97
- if (found.length === 0) {
136
+ if (copies.length === 0) {
98
137
  return {
99
138
  ok: false,
100
139
  detail: `${SKILL_NAME} is in neither ${CLAUDE_SKILLS_DIR} nor ${CODEX_SKILLS_DIR}`,
101
140
  hint: 'run `pr-review install-skill`',
102
141
  }
103
142
  }
104
- return { ok: true, detail: found.join(', ') }
143
+ return { ok: true, detail: copies.map(copy => copy.path).join(', ') }
105
144
  }
106
145
 
107
146
  async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
108
- const hint = 'install with `npm install -g acpx` and check `acpx --version`'
147
+ const hint = 'install with `npm install -g acpx@latest` and check `acpx --version`'
109
148
  try {
110
149
  const version = (await deps.acpxVersion())?.trim()
111
150
  return version
@@ -130,25 +169,30 @@ export async function runDoctorChecks(
130
169
  git = { ok: false, detail: message(err), hint: 'run from a clone or pass --repo <dir>' }
131
170
  }
132
171
 
172
+ let host = GITHUB_HOST
133
173
  let origin: DoctorCheck
134
174
  try {
135
175
  const url = await deps.git.remoteUrl('origin')
136
- const repo = url === null ? null : parseGithubRemote(url)
137
- origin =
138
- repo === null
139
- ? { ok: false, detail: url ?? 'no origin remote', hint: 'add a github.com origin' }
140
- : { ok: true, detail: `${repo.owner}/${repo.name}` }
176
+ const parsed = url === null ? null : parseOriginRemote(url, deps.env)
177
+ if (parsed === null) {
178
+ origin = { ok: false, detail: url ?? 'no origin remote', hint: ORIGIN_HINT }
179
+ } else {
180
+ host = parsed.host
181
+ origin = { ok: true, detail: `${parsed.repo.owner}/${parsed.repo.name} (${host.label})` }
182
+ }
141
183
  } catch (err) {
142
- origin = { ok: false, detail: message(err), hint: 'add a github.com origin' }
184
+ origin = { ok: false, detail: message(err), hint: ORIGIN_HINT }
143
185
  }
144
186
 
145
- const status = await deps.gh.authStatus()
187
+ // The `gh` keys are the report's public names; for a GitLab origin they describe glab.
188
+ const status = await deps.client(host).authStatus()
189
+ const cli = CLI_INFO[host.cli.cli]
146
190
  const gh: DoctorCheck = status.installed
147
191
  ? { ok: true, detail: status.detail }
148
- : { ok: false, detail: status.detail, hint: 'install it from https://cli.github.com' }
192
+ : { ok: false, detail: status.detail, hint: `install it from ${cli.installUrl}` }
149
193
  const ghAuth: DoctorCheck = status.authenticated
150
194
  ? { ok: true, detail: status.detail }
151
- : { ok: false, detail: status.detail, hint: 'run `gh auth login`' }
195
+ : { ok: false, detail: status.detail, hint: `run \`${cli.loginCommand}\`` }
152
196
 
153
197
  let dataDir: DoctorCheck
154
198
  try {
@@ -0,0 +1,107 @@
1
+ // Incremental generation: the basis canvas an update starts from, the per-file delta between that
2
+ // canvas's diff and the head's, and the split of the basis into content the head leaves untouched
3
+ // and content the generator has to decide anew. Everything here is a function of two diffs and one
4
+ // stored canvas, so `prepare` states the split instead of asking the generator to work it out.
5
+ import type {
6
+ BasisSplit,
7
+ BasisSplitLayer,
8
+ BasisSplitPoint,
9
+ FileDelta,
10
+ } from '../contract/generation-context.js'
11
+ import type { ReviewArtifact } from '../contract/review-artifact.js'
12
+ import type { Git } from '../git/git.js'
13
+ import type { CanvasStore } from '../store/canvas-store.js'
14
+ import type { Derived } from '../store/derived-store.js'
15
+
16
+ /**
17
+ * Which files the head changes relative to the basis canvas's diff. A file is unchanged only when
18
+ * its whole patch is byte-identical: line numbers, context, and all. Anything less would let a
19
+ * fold or an annotation of the basis land on a line it was never written for. Renames arrive as a
20
+ * removal and an addition, since a file's key follows its path.
21
+ *
22
+ * The two sides are matched by path, and each side's patch is read through its own key. A key is
23
+ * the sanitized path, and two paths can sanitize to the same key (`a-b.ts` and `a_b.ts`), which
24
+ * `uniqueKey` then separates by the order the files appear in that one diff. The same key can
25
+ * therefore name different files in two diffs; the path cannot.
26
+ */
27
+ export function fileDelta(basis: Derived, head: Derived): FileDelta {
28
+ const keysByPath = (d: Derived): Map<string, string> => new Map(d.files.map(f => [f.path, f.key]))
29
+ const basisKeys = keysByPath(basis)
30
+ const headKeys = keysByPath(head)
31
+ const delta: FileDelta = { unchanged: [], changed: [], added: [], removed: [] }
32
+ for (const [path, headKey] of headKeys) {
33
+ const basisKey = basisKeys.get(path)
34
+ if (basisKey === undefined) {
35
+ delta.added.push(path)
36
+ } else if (basis.patches[basisKey] === head.patches[headKey]) {
37
+ delta.unchanged.push(path)
38
+ } else {
39
+ delta.changed.push(path)
40
+ }
41
+ }
42
+ for (const path of basisKeys.keys()) {
43
+ if (!headKeys.has(path)) {
44
+ delta.removed.push(path)
45
+ }
46
+ }
47
+ for (const list of Object.values(delta)) {
48
+ list.sort()
49
+ }
50
+ return delta
51
+ }
52
+
53
+ /**
54
+ * The basis canvas divided in two. A layer is carried whole when the head touches none of its
55
+ * files; otherwise the layer is re-judged, and only the files the head leaves alone keep their
56
+ * note, folds, and annotations. A point is carried when the file it sits in is untouched, which
57
+ * keeps its title and so its fingerprint, and with it any dismissal the reviewer made.
58
+ */
59
+ export function splitBasis(
60
+ artifact: ReviewArtifact,
61
+ delta: FileDelta
62
+ ): Omit<BasisSplit, 'canvasSha' | 'reviewJsonPath' | 'files'> {
63
+ const unchanged = new Set(delta.unchanged)
64
+ const layers: BasisSplitLayer[] = artifact.layers.map(layer => {
65
+ const carriedFiles = layer.files.filter(f => unchanged.has(f.path)).map(f => f.path)
66
+ const reJudgedFiles = layer.files.filter(f => !unchanged.has(f.path)).map(f => f.path)
67
+ return {
68
+ key: layer.key,
69
+ title: layer.title,
70
+ status: reJudgedFiles.length === 0 ? 'carried' : 're-judged',
71
+ carriedFiles,
72
+ reJudgedFiles,
73
+ }
74
+ })
75
+ const points: BasisSplitPoint[] = artifact.points.map(point => ({
76
+ kind: point.kind,
77
+ path: point.path,
78
+ title: point.title,
79
+ status: unchanged.has(point.path) ? 'carried' : 're-judged',
80
+ }))
81
+ return { layers, points }
82
+ }
83
+
84
+ /**
85
+ * The canvas an incremental run builds on: the newest one generated for a commit this head was
86
+ * built on. A canvas of a line of work the head no longer contains describes code that was
87
+ * abandoned, so it is never a basis, however recent it is.
88
+ */
89
+ export async function findBasisCanvas(
90
+ canvases: CanvasStore,
91
+ git: Git,
92
+ prNumber: number | undefined,
93
+ headSha: string
94
+ ): Promise<string | null> {
95
+ const index = await canvases.readIndex()
96
+ const candidates = Object.entries(index.canvases)
97
+ .filter(
98
+ ([sha, entry]) => sha !== headSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
99
+ )
100
+ .sort(([, a], [, b]) => (a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0))
101
+ for (const [sha] of candidates) {
102
+ if (await git.isAncestor(sha, headSha)) {
103
+ return sha
104
+ }
105
+ }
106
+ return null
107
+ }
@@ -29,6 +29,8 @@ export interface NormalizeInput {
29
29
  caps: TextCaps
30
30
  generatedAt: string
31
31
  generator: Generator
32
+ /** The canvas this one was generated from, when the run was incremental. */
33
+ basisCanvasSha?: string | undefined
32
34
  /** The globs that make a file a test; the project config's list, or the built-in one. */
33
35
  testPatterns?: readonly string[] | undefined
34
36
  }
@@ -76,16 +78,20 @@ function unionRisk(layers: readonly Layer[]): RiskTag[] {
76
78
  return out
77
79
  }
78
80
 
81
+ /**
82
+ * A layer's id is its own key, which the validator has already checked is unique here. A reviewed
83
+ * mark is keyed by it, so a regenerated canvas that reorders or renames its layers keeps the
84
+ * reviewer's progress pointing at the same concern; a position could not.
85
+ */
79
86
  function toLayer(
80
87
  layer: ModelLayer,
81
- index: number,
82
88
  highRisk: readonly HighRiskRule[],
83
89
  testPatterns: readonly string[]
84
90
  ): Layer {
85
91
  const { risk: _modelRisk, files, ...rest } = layer
86
92
  return {
87
93
  ...rest,
88
- id: `layer-${index + 1}`,
94
+ id: layer.key,
89
95
  risk: layerRisk(layer, highRisk),
90
96
  files: files.map(f => ({ ...f, isTest: isTestPath(f.path, testPatterns) })),
91
97
  }
@@ -167,12 +173,12 @@ function sortPoints(points: Unassigned[]): Point[] {
167
173
 
168
174
  export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArtifact {
169
175
  const testPatterns = input.testPatterns ?? DEFAULT_TEST_PATTERNS
170
- const layers = output.layers.map((l, i) => toLayer(l, i, input.highRisk, testPatterns))
176
+ const layers = output.layers.map(l => toLayer(l, input.highRisk, testPatterns))
171
177
  const points = [
172
178
  ...output.points.map(p => modelPoint(p, layers, input.files)),
173
179
  ...layers.flatMap(l => testPoints(l, input.files, input.caps.pointTitle)),
174
180
  ]
175
- return {
181
+ const artifact: ReviewArtifact = {
176
182
  version: 1,
177
183
  pr: input.pr,
178
184
  files: [...input.files],
@@ -184,6 +190,10 @@ export function normalize(output: ModelOutput, input: NormalizeInput): ReviewArt
184
190
  generator: input.generator,
185
191
  source: 'local',
186
192
  }
193
+ if (input.basisCanvasSha !== undefined) {
194
+ artifact.basisCanvasSha = input.basisCanvasSha
195
+ }
196
+ return artifact
187
197
  }
188
198
 
189
199
  /**
@@ -2,16 +2,28 @@
2
2
  // the canvas directory. The agent reads those two files; publish reads context.json back.
3
3
  import { appendFile, readdir, rm } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { type GenerationContext, isLargePr, type PrepareTarget } from '../contract/generation-context.js'
5
+ import {
6
+ type BasisSplit,
7
+ type GenerationContext,
8
+ isLargePr,
9
+ type PrepareTarget,
10
+ type PrepareTargetInput,
11
+ } from '../contract/generation-context.js'
6
12
  import { effectiveCaps, LIMITS, type Pr } from '../contract/review-artifact.js'
7
- import { fetchPrMeta, fetchPrRefs, toPr } from '../github/pr.js'
13
+ import type { LocalKey } from '../contract/review-key.js'
14
+ import { describeLocalWork, resolveLocalBase, UNCOMMITTED_STATE } from '../git/local-target.js'
15
+ import { fetchPrRefs } from '../git/pr-refs.js'
16
+
17
+ import { toPr } from '../host/pr.js'
8
18
  import type { AppContext } from '../server/context.js'
9
19
  import { readText, writeJsonAtomic, writeTextAtomic } from '../store/atomic-json.js'
20
+ import type { Derived } from '../store/derived-store.js'
21
+ import { fileDelta, findBasisCanvas, splitBasis } from './incremental.js'
10
22
  import { loadPromptSources, type PromptSources, renderPrompt } from './prompt.js'
11
23
 
12
24
  export interface PrepareOptions {
13
25
  force: boolean
14
- /** Progress lines: `fetch-pr`, `fetch-refs`, `collect-diffs`, `prompt`. */
26
+ /** Progress lines: `fetch-pr`, `fetch-refs`, `snapshot`, `collect-diffs`, `prompt`. */
15
27
  log: (phase: string) => void
16
28
  promptSources?: PromptSources
17
29
  }
@@ -23,16 +35,40 @@ export interface PrepareResult {
23
35
  promptPath: string
24
36
  contextPath: string
25
37
  status: 'prepared' | 'exists'
38
+ /** Local targets only: which review it is, the base resolved for it, and what its head holds. */
39
+ local?: { review: LocalKey; base: string; headRef: string; uncommitted: boolean }
26
40
  }
27
41
 
28
42
  /** The PR meta, live from GitHub, with the head and base refs fetched into the local clone. */
29
43
  async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['log']): Promise<Pr> {
30
44
  log('fetch-pr')
31
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
45
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, number)
32
46
  log('fetch-refs')
33
- const shas = await fetchPrRefs(ctx.git, meta)
47
+ const shas = await fetchPrRefs(ctx.git, ctx.config.host, meta)
34
48
  const pr = toPr(meta, ctx.config.repo, shas)
35
- await ctx.prs.writePr(pr)
49
+ await ctx.prs.writePr(number, pr)
50
+ return pr
51
+ }
52
+
53
+ /**
54
+ * The work in this clone that has no pull request yet: the current branch, or a snapshot commit
55
+ * of the working tree when it carries edits. The meta is cached under `prs/<branch|uncommitted>/`,
56
+ * which is what the matching page reads.
57
+ */
58
+ async function resolveLocal(
59
+ ctx: AppContext,
60
+ target: Extract<PrepareTarget, { kind: 'local' }>,
61
+ log: PrepareOptions['log']
62
+ ): Promise<Pr> {
63
+ log('snapshot')
64
+ const pr = await describeLocalWork(ctx.git, {
65
+ base: target.base,
66
+ source: target.source,
67
+ repo: ctx.config.repo,
68
+ now: ctx.now,
69
+ })
70
+ await ctx.prs.writePr(target.source, pr)
71
+ await ctx.prs.writeLocalTarget(target.source, target)
36
72
  return pr
37
73
  }
38
74
 
@@ -53,7 +89,7 @@ async function resolveRefs(
53
89
  title: head,
54
90
  body: '',
55
91
  author,
56
- url: `https://github.com/${repo.owner}/${repo.name}/compare/${base}...${head}`,
92
+ url: ctx.config.host.compareUrl(repo, base, head),
57
93
  state: 'pre-pr',
58
94
  draft: false,
59
95
  updatedAt: ctx.now().toISOString(),
@@ -98,25 +134,83 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
98
134
  }
99
135
  }
100
136
 
101
- export async function prepare(
137
+ /**
138
+ * The basis canvas of this run, split into what carries and what is re-judged, or null when the
139
+ * canvas is generated from a blank page: `--force`, `canvas.incremental: false`, no canvas of an
140
+ * ancestor commit, or a basis whose canvas or diff this machine can no longer read.
141
+ *
142
+ * Only a pull request's canvases are owned by a number; a refs or local run considers the canvases
143
+ * that carry no number, the same ownership rule the store's own lookup uses.
144
+ */
145
+ async function resolveBasis(
102
146
  ctx: AppContext,
103
147
  target: PrepareTarget,
148
+ pr: Pr,
149
+ head: Derived
150
+ ): Promise<BasisSplit | null> {
151
+ const prNumber = target.kind === 'pr' ? target.number : undefined
152
+ const sha = await findBasisCanvas(ctx.canvases, ctx.git, prNumber, pr.headSha)
153
+ if (sha === null) {
154
+ return null
155
+ }
156
+ const [artifact, manifest] = await Promise.all([
157
+ ctx.canvases.readArtifact(sha),
158
+ ctx.canvases.readManifest(sha),
159
+ ])
160
+ if (artifact === null || manifest === null) {
161
+ return null
162
+ }
163
+ const basis = await ctx.derived.readOrBuild(sha, manifest.mergeBaseSha)
164
+ if (basis === null) {
165
+ return null
166
+ }
167
+ const files = fileDelta(basis, head)
168
+ return {
169
+ canvasSha: sha,
170
+ reviewJsonPath: path.join(ctx.canvases.canvasDir(sha), 'review.json'),
171
+ files,
172
+ ...splitBasis(artifact, files),
173
+ }
174
+ }
175
+
176
+ /** The target with its base resolved, which is the form `context.json` records. */
177
+ async function resolveTarget(ctx: AppContext, input: PrepareTargetInput): Promise<PrepareTarget> {
178
+ if (input.kind !== 'local') {
179
+ return input
180
+ }
181
+ return { kind: 'local', source: input.source, base: await resolveLocalBase(ctx.git, input.base) }
182
+ }
183
+
184
+ export async function prepare(
185
+ ctx: AppContext,
186
+ input: PrepareTargetInput,
104
187
  opts: PrepareOptions
105
188
  ): Promise<PrepareResult> {
189
+ const target = await resolveTarget(ctx, input)
106
190
  const pr =
107
191
  target.kind === 'pr'
108
192
  ? await resolvePr(ctx, target.number, opts.log)
109
- : await resolveRefs(ctx, target.base, target.head, opts.log)
193
+ : target.kind === 'local'
194
+ ? await resolveLocal(ctx, target, opts.log)
195
+ : await resolveRefs(ctx, target.base, target.head, opts.log)
110
196
  const canvasDir = ctx.canvases.canvasDir(pr.headSha)
111
197
  const promptPath = path.join(canvasDir, 'prompt.md')
112
198
  const contextPath = path.join(canvasDir, 'context.json')
113
- const result = {
199
+ const result: Omit<PrepareResult, 'status'> = {
114
200
  canvasDir,
115
201
  headSha: pr.headSha,
116
202
  mergeBaseSha: pr.mergeBaseSha,
117
203
  promptPath,
118
204
  contextPath,
119
205
  }
206
+ if (target.kind === 'local') {
207
+ result.local = {
208
+ review: target.source,
209
+ base: target.base,
210
+ headRef: pr.headRef,
211
+ uncommitted: pr.state === UNCOMMITTED_STATE,
212
+ }
213
+ }
120
214
  if (!opts.force && (await ctx.canvases.exists(pr.headSha))) {
121
215
  return { ...result, status: 'exists' }
122
216
  }
@@ -125,6 +219,8 @@ export async function prepare(
125
219
  const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
126
220
  const additions = derived.files.reduce((n, f) => n + f.additions, 0)
127
221
  const deletions = derived.files.reduce((n, f) => n + f.deletions, 0)
222
+ // A pull request's counts are the forge's; anything else is counted from the diff itself. The
223
+ // page derives its own from the files it shows, so this is only what the canvas records.
128
224
  const fullPr: Pr =
129
225
  target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
130
226
  const derivedDir = ctx.derived.derivedDir(pr.headSha)
@@ -162,6 +258,11 @@ export async function prepare(
162
258
  largePr: isLargePr({ files: derived.files.length, additions, deletions }),
163
259
  preparedAt: ctx.now().toISOString(),
164
260
  }
261
+ // `--force` means start over, so it never reads a basis, whatever the project config says.
262
+ const basis = opts.force || !config.canvas.incremental ? null : await resolveBasis(ctx, target, pr, derived)
263
+ if (basis !== null) {
264
+ context.basis = basis
265
+ }
165
266
  const sources =
166
267
  opts.promptSources ??
167
268
  (await loadPromptSources(undefined, {