@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.
- package/README.md +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +64 -23
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- 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
|
+
}
|
package/src/review/doctor.ts
CHANGED
|
@@ -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 {
|
|
6
|
+
import { ORIGIN_HINT } from '../config.js'
|
|
7
7
|
import type { Git } from '../git/git.js'
|
|
8
|
-
import type
|
|
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
|
-
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
67
|
-
const found: string[] = []
|
|
68
|
-
const stale: string[] = []
|
|
69
|
-
let expected: string
|
|
120
|
+
let copies: SkillCopy[]
|
|
70
121
|
try {
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
184
|
+
origin = { ok: false, detail: message(err), hint: ORIGIN_HINT }
|
|
143
185
|
}
|
|
144
186
|
|
|
145
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/src/review/normalize.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|
package/src/review/prepare.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
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, {
|