@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import type {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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
|
}
|
|
@@ -47,6 +88,43 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
47
88
|
const readIndex = async (): Promise<CanvasIndex> =>
|
|
48
89
|
(await readJson(indexFile, CanvasIndexSchema)) ?? { canvases: {} }
|
|
49
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
|
+
}
|
|
127
|
+
|
|
50
128
|
return {
|
|
51
129
|
root: repoRoot,
|
|
52
130
|
canvasDir,
|
|
@@ -54,12 +132,12 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
54
132
|
exists: async headSha => (await readText(path.join(canvasDir(headSha), 'review.json'))) !== null,
|
|
55
133
|
readArtifact: headSha => readJson(path.join(canvasDir(headSha), 'review.json'), ReviewArtifactSchema),
|
|
56
134
|
readManifest: headSha => readJson(path.join(canvasDir(headSha), 'manifest.json'), CanvasManifestSchema),
|
|
57
|
-
write: async (headSha, artifact, manifest, prNumber) => {
|
|
135
|
+
write: async (headSha, artifact, manifest, prNumber, flags) => {
|
|
58
136
|
const dir = canvasDir(headSha)
|
|
59
137
|
await writeJsonAtomic(path.join(dir, 'review.json'), artifact)
|
|
60
138
|
await writeJsonAtomic(path.join(dir, 'manifest.json'), manifest)
|
|
61
139
|
const index = await readIndex()
|
|
62
|
-
const entry:
|
|
140
|
+
const entry: CanvasEntry = {
|
|
63
141
|
generatedAt: artifact.generatedAt,
|
|
64
142
|
source: artifact.source,
|
|
65
143
|
}
|
|
@@ -70,57 +148,17 @@ export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
|
70
148
|
if (artifact.importedAt !== undefined) {
|
|
71
149
|
entry.importedAt = artifact.importedAt
|
|
72
150
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
76
|
-
findForPr: async (prNumber, currentHeadSha) => {
|
|
77
|
-
const index = await readIndex()
|
|
78
|
-
if (index.canvases[currentHeadSha] !== undefined) {
|
|
79
|
-
return { status: 'ready', headSha: currentHeadSha }
|
|
80
|
-
}
|
|
81
|
-
// A canvas belongs to this PR when it was exported for it, or before the PR existed.
|
|
82
|
-
const candidates = Object.entries(index.canvases).filter(
|
|
83
|
-
([sha, entry]) =>
|
|
84
|
-
sha !== currentHeadSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
|
|
85
|
-
)
|
|
86
|
-
const ranked: Array<{
|
|
87
|
-
headSha: string
|
|
88
|
-
generatedAt: string
|
|
89
|
-
relation: CanvasRelation
|
|
90
|
-
commitsBehind?: number
|
|
91
|
-
}> = []
|
|
92
|
-
for (const [sha, entry] of candidates) {
|
|
93
|
-
if (await git.isAncestor(sha, currentHeadSha)) {
|
|
94
|
-
ranked.push({
|
|
95
|
-
headSha: sha,
|
|
96
|
-
generatedAt: entry.generatedAt,
|
|
97
|
-
relation: 'ancestor',
|
|
98
|
-
commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
|
|
99
|
-
})
|
|
100
|
-
} else {
|
|
101
|
-
ranked.push({ headSha: sha, generatedAt: entry.generatedAt, relation: 'unrelated' })
|
|
102
|
-
}
|
|
151
|
+
if (artifact.basisCanvasSha !== undefined) {
|
|
152
|
+
entry.basisCanvasSha = artifact.basisCanvasSha
|
|
103
153
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (a.relation !== b.relation) {
|
|
107
|
-
return a.relation === 'ancestor' ? -1 : 1
|
|
108
|
-
}
|
|
109
|
-
return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
|
|
110
|
-
})
|
|
111
|
-
const best = ranked[0]
|
|
112
|
-
if (best === undefined) {
|
|
113
|
-
return { status: 'missing' }
|
|
154
|
+
if (flags?.worktree === true) {
|
|
155
|
+
entry.worktree = true
|
|
114
156
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: {
|
|
118
|
-
status: 'stale',
|
|
119
|
-
headSha: best.headSha,
|
|
120
|
-
relation: best.relation,
|
|
121
|
-
commitsBehind: best.commitsBehind,
|
|
122
|
-
}
|
|
157
|
+
index.canvases[headSha] = entry
|
|
158
|
+
await writeJsonAtomic(indexFile, index)
|
|
123
159
|
},
|
|
160
|
+
findForPr: (prNumber, currentHeadSha) => rank(currentHeadSha, entry => canvasBelongsTo(entry, prNumber)),
|
|
161
|
+
findForLocal: (key, currentHeadSha) => rank(currentHeadSha, entry => canvasBelongsTo(entry, key)),
|
|
124
162
|
attachPrNumber: async (headSha, prNumber) => {
|
|
125
163
|
const index = await readIndex()
|
|
126
164
|
const entry = index.canvases[headSha]
|
package/src/store/data-dir.ts
CHANGED
|
@@ -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
|
-
|
|
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,36 +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
|
|
53
|
-
|
|
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'), {
|
|
73
|
-
headSha,
|
|
74
|
-
mergeBaseSha,
|
|
75
|
-
builtAt: now().toISOString(),
|
|
76
|
-
})
|
|
77
|
-
return { files, patches }
|
|
78
|
-
},
|
|
87
|
+
derivable,
|
|
88
|
+
ensure,
|
|
79
89
|
read,
|
|
90
|
+
readOrBuild: async (headSha, mergeBaseSha) =>
|
|
91
|
+
mergeBaseSha !== undefined && (await derivable(headSha, mergeBaseSha))
|
|
92
|
+
? ensure(headSha, mergeBaseSha)
|
|
93
|
+
: read(headSha),
|
|
80
94
|
readLines: async (headSha, side, filePath, from, to) => {
|
|
81
95
|
const root = path.join(derivedDir(headSha), side)
|
|
82
96
|
const full = path.resolve(root, filePath)
|
package/src/store/pr-store.ts
CHANGED
|
@@ -2,39 +2,48 @@ 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
|
-
/**
|
|
10
|
+
/** Target-keyed files: the cached PR meta and the last comments payload. */
|
|
9
11
|
export interface PrStore {
|
|
10
|
-
prDir(
|
|
11
|
-
readPr(
|
|
12
|
-
|
|
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 = (
|
|
24
|
-
if (!Number.isInteger(
|
|
25
|
-
throw new Error(`not a pull request 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',
|
|
33
|
+
return path.join(repoRoot, 'prs', keyToString(key))
|
|
28
34
|
}
|
|
29
35
|
return {
|
|
30
36
|
prDir,
|
|
31
|
-
readPr:
|
|
32
|
-
writePr: async pr => {
|
|
33
|
-
if (pr.number
|
|
34
|
-
throw new Error(
|
|
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(
|
|
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 =>
|
|
@@ -11,11 +11,15 @@ export const SETTINGS_TEMPLATE = `# Personal pr-review settings. Gitignored: thi
|
|
|
11
11
|
version: 1
|
|
12
12
|
|
|
13
13
|
# The look of the page: terminal or github.
|
|
14
|
-
skin:
|
|
14
|
+
skin: ${DEFAULT_SETTINGS.skin}
|
|
15
15
|
|
|
16
16
|
# Light or dark: auto follows the operating system, light and dark pin one.
|
|
17
17
|
theme: auto
|
|
18
18
|
|
|
19
|
+
# How much code a review hides when it opens: light, moderate, or aggressive. The Hide code
|
|
20
|
+
# control on the page changes it for that page only.
|
|
21
|
+
foldLevel: light
|
|
22
|
+
|
|
19
23
|
# Which agent answers in the AI Chat pane: claude or codex.
|
|
20
24
|
agent: claude
|
|
21
25
|
|
|
@@ -121,6 +125,7 @@ export function applySettings(text: string, input: SettingsInput): { text: strin
|
|
|
121
125
|
doc.set('version', 1)
|
|
122
126
|
doc.set('skin', settings.skin)
|
|
123
127
|
doc.set('theme', settings.theme)
|
|
128
|
+
doc.set('foldLevel', settings.foldLevel)
|
|
124
129
|
doc.set('agent', settings.agent)
|
|
125
130
|
doc.set('model', settings.model)
|
|
126
131
|
doc.set('chatTimeoutSec', settings.chatTimeoutSec)
|
|
@@ -136,6 +141,9 @@ function stripUndefined(input: SettingsInput): Partial<Settings> {
|
|
|
136
141
|
if (input.theme !== undefined) {
|
|
137
142
|
out.theme = input.theme
|
|
138
143
|
}
|
|
144
|
+
if (input.foldLevel !== undefined) {
|
|
145
|
+
out.foldLevel = input.foldLevel
|
|
146
|
+
}
|
|
139
147
|
if (input.agent !== undefined) {
|
|
140
148
|
out.agent = input.agent
|
|
141
149
|
}
|
package/src/store/state-store.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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'
|
|
4
|
+
import type { AddPendingInput, PendingComment } from '../contract/pending.js'
|
|
3
5
|
import { readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
|
|
4
6
|
import type { PrStore } from './pr-store.js'
|
|
5
7
|
|
|
@@ -9,23 +11,45 @@ export interface PostedEntry {
|
|
|
9
11
|
pointFingerprint?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
/** Per-
|
|
14
|
+
/** Per-target local state: reviewed cards, dismissed points, hidden threads, posted comments. */
|
|
13
15
|
export interface StateStore {
|
|
14
16
|
/** Defaults when the file is missing or does not match the schema (an older tool version wrote it). */
|
|
15
|
-
read(
|
|
17
|
+
read(key: ReviewKey): Promise<PrState>
|
|
16
18
|
/**
|
|
17
|
-
* Read, change, write. Calls for the same
|
|
19
|
+
* Read, change, write. Calls for the same target run one after another, so two requests that
|
|
18
20
|
* arrive together both land instead of one overwriting the other.
|
|
19
21
|
*/
|
|
20
|
-
update(
|
|
22
|
+
update(key: ReviewKey, mutate: (state: PrState) => PrState): Promise<PrState>
|
|
21
23
|
/**
|
|
22
|
-
* Marks one layer or file. `
|
|
23
|
-
* the one the marks describe, the old marks are dropped, because they were about
|
|
24
|
+
* Marks one layer or file. `canvasSha` is the commit of the canvas the page was showing: when it
|
|
25
|
+
* differs from the one the marks describe, the old marks are dropped, because they were about
|
|
26
|
+
* other code, and `carried` takes their place — the marks the caller worked out may follow that
|
|
27
|
+
* canvas from the one it was generated from.
|
|
24
28
|
*/
|
|
25
|
-
setReviewed(
|
|
26
|
-
setDismissed(
|
|
27
|
-
setThreadHidden(
|
|
28
|
-
addPosted(
|
|
29
|
+
setReviewed(key: ReviewKey, id: string, reviewed: boolean, opts?: SetReviewedOptions): Promise<PrState>
|
|
30
|
+
setDismissed(key: ReviewKey, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
|
|
31
|
+
setThreadHidden(key: ReviewKey, rootCommentId: number, hidden: boolean): Promise<PrState>
|
|
32
|
+
addPosted(key: ReviewKey, entry: PostedEntry): Promise<PrState>
|
|
33
|
+
/** Adds one comment to the pending review and returns the state that holds it. */
|
|
34
|
+
addPending(key: ReviewKey, input: AddPendingInput, headSha: string): Promise<PrState>
|
|
35
|
+
/** Rewrites one draft's body. A draft that is no longer there leaves the state alone. */
|
|
36
|
+
editPending(key: ReviewKey, id: string, body: string): Promise<PrState>
|
|
37
|
+
removePending(key: ReviewKey, id: string): Promise<PrState>
|
|
38
|
+
/** Discards every draft at the reviewer's request. */
|
|
39
|
+
clearPending(key: ReviewKey): Promise<PrState>
|
|
40
|
+
/** Acknowledge submitted versions, preserving drafts added or edited while posting. */
|
|
41
|
+
completePending(
|
|
42
|
+
key: ReviewKey,
|
|
43
|
+
submitted: ReadonlyArray<PendingComment>,
|
|
44
|
+
posted: PostedEntry[]
|
|
45
|
+
): Promise<PrState>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SetReviewedOptions {
|
|
49
|
+
/** The commit of the canvas the page was showing. */
|
|
50
|
+
canvasSha?: string | undefined
|
|
51
|
+
/** Marks that follow `canvasSha` from the canvas it was generated from; used on the first write. */
|
|
52
|
+
carried?: Readonly<Record<string, true>> | undefined
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
/** `layer:<id>` or `layer:<id>/file:<key>`, with the ids and keys the artifact uses. */
|
|
@@ -41,26 +65,32 @@ function layerOf(id: string): string {
|
|
|
41
65
|
return at === -1 ? id : id.slice(0, at)
|
|
42
66
|
}
|
|
43
67
|
|
|
68
|
+
/** Ids only have to be unique inside one state file; the clock and the counter make them so. */
|
|
69
|
+
function pendingId(now: () => Date, seq: number): string {
|
|
70
|
+
return `p${now().getTime().toString(36)}-${seq.toString(36)}`
|
|
71
|
+
}
|
|
72
|
+
|
|
44
73
|
export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
74
|
+
let pendingSeq = 0
|
|
75
|
+
const file = (key: ReviewKey): string => path.join(prs.prDir(key), 'state.json')
|
|
76
|
+
const read = (key: ReviewKey): Promise<PrState> =>
|
|
77
|
+
readJsonOrDefault(file(key), PrStateSchema, () => emptyState(now().toISOString()))
|
|
48
78
|
|
|
49
|
-
/** One chain per
|
|
50
|
-
const chains = new Map<
|
|
79
|
+
/** One chain per target; each update waits for the one before it. */
|
|
80
|
+
const chains = new Map<ReviewKey, Promise<unknown>>()
|
|
51
81
|
|
|
52
|
-
const update = (
|
|
82
|
+
const update = (key: ReviewKey, mutate: (state: PrState) => PrState): Promise<PrState> => {
|
|
53
83
|
const run = async (): Promise<PrState> => {
|
|
54
|
-
const current = await read(
|
|
84
|
+
const current = await read(key)
|
|
55
85
|
// Every write counts up, so the page can tell which of two answers was written later.
|
|
56
86
|
const next = { ...mutate(current), rev: (current.rev ?? 0) + 1, updatedAt: now().toISOString() }
|
|
57
|
-
await writeJsonAtomic(file(
|
|
87
|
+
await writeJsonAtomic(file(key), next)
|
|
58
88
|
return next
|
|
59
89
|
}
|
|
60
|
-
const chained = (chains.get(
|
|
90
|
+
const chained = (chains.get(key) ?? Promise.resolve()).then(run, run)
|
|
61
91
|
// The chain only orders the calls, so a failed update never blocks the next one.
|
|
62
92
|
chains.set(
|
|
63
|
-
|
|
93
|
+
key,
|
|
64
94
|
chained.catch(() => undefined)
|
|
65
95
|
)
|
|
66
96
|
return chained
|
|
@@ -69,28 +99,31 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
|
69
99
|
return {
|
|
70
100
|
read,
|
|
71
101
|
update,
|
|
72
|
-
setReviewed: (
|
|
73
|
-
update(
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
setReviewed: (key, id, reviewed, opts) =>
|
|
103
|
+
update(key, state => {
|
|
104
|
+
const canvasSha = opts?.canvasSha
|
|
105
|
+
const sameCanvas =
|
|
106
|
+
canvasSha === undefined ||
|
|
107
|
+
state.reviewedCanvasSha === undefined ||
|
|
108
|
+
state.reviewedCanvasSha === canvasSha
|
|
109
|
+
const next = sameCanvas ? { ...state.reviewed } : { ...opts?.carried }
|
|
77
110
|
if (reviewed) {
|
|
78
111
|
next[id] = true
|
|
79
112
|
} else {
|
|
80
|
-
for (const
|
|
113
|
+
for (const marked of Object.keys(next)) {
|
|
81
114
|
// Reopening a layer reopens its files, and reopening a file reopens its layer:
|
|
82
115
|
// both readings of "reviewed" have to agree.
|
|
83
|
-
if (
|
|
84
|
-
delete next[
|
|
116
|
+
if (marked === id || marked.startsWith(`${id}/`) || marked === layerOf(id)) {
|
|
117
|
+
delete next[marked]
|
|
85
118
|
}
|
|
86
119
|
}
|
|
87
120
|
}
|
|
88
|
-
return
|
|
121
|
+
return canvasSha === undefined
|
|
89
122
|
? { ...state, reviewed: next }
|
|
90
|
-
: { ...state, reviewed: next,
|
|
123
|
+
: { ...state, reviewed: next, reviewedCanvasSha: canvasSha }
|
|
91
124
|
}),
|
|
92
|
-
setDismissed: (
|
|
93
|
-
update(
|
|
125
|
+
setDismissed: (key, fingerprint, dismissed, reason) =>
|
|
126
|
+
update(key, state => {
|
|
94
127
|
const next = { ...state.dismissed }
|
|
95
128
|
if (dismissed) {
|
|
96
129
|
next[fingerprint] =
|
|
@@ -100,8 +133,8 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
|
100
133
|
}
|
|
101
134
|
return { ...state, dismissed: next }
|
|
102
135
|
}),
|
|
103
|
-
setThreadHidden: (
|
|
104
|
-
update(
|
|
136
|
+
setThreadHidden: (key, rootCommentId, hidden) =>
|
|
137
|
+
update(key, state => {
|
|
105
138
|
const next = { ...state.hiddenThreads }
|
|
106
139
|
if (hidden) {
|
|
107
140
|
next[String(rootCommentId)] = { at: now().toISOString() }
|
|
@@ -110,13 +143,49 @@ export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
|
110
143
|
}
|
|
111
144
|
return { ...state, hiddenThreads: next }
|
|
112
145
|
}),
|
|
113
|
-
addPosted: (
|
|
114
|
-
update(
|
|
146
|
+
addPosted: (key, entry) =>
|
|
147
|
+
update(key, state => {
|
|
115
148
|
if (state.posted.some(p => p.commentId === entry.commentId)) {
|
|
116
149
|
return state
|
|
117
150
|
}
|
|
118
151
|
const posted = { ...entry, at: now().toISOString() }
|
|
119
152
|
return { ...state, posted: [...state.posted, posted] }
|
|
120
153
|
}),
|
|
154
|
+
addPending: (key, input, headSha) => {
|
|
155
|
+
pendingSeq += 1
|
|
156
|
+
const at = now().toISOString()
|
|
157
|
+
const draft: PendingComment = {
|
|
158
|
+
id: pendingId(now, pendingSeq),
|
|
159
|
+
path: input.path,
|
|
160
|
+
line: input.line,
|
|
161
|
+
side: input.side,
|
|
162
|
+
...(input.startLine === undefined ? {} : { startLine: input.startLine }),
|
|
163
|
+
body: input.body,
|
|
164
|
+
...(input.pointFingerprint === undefined ? {} : { pointFingerprint: input.pointFingerprint }),
|
|
165
|
+
headSha,
|
|
166
|
+
createdAt: at,
|
|
167
|
+
updatedAt: at,
|
|
168
|
+
}
|
|
169
|
+
return update(key, state => ({ ...state, pending: [...state.pending, draft] }))
|
|
170
|
+
},
|
|
171
|
+
editPending: (key, id, body) =>
|
|
172
|
+
update(key, state => ({
|
|
173
|
+
...state,
|
|
174
|
+
pending: state.pending.map(p => (p.id === id ? { ...p, body, updatedAt: now().toISOString() } : p)),
|
|
175
|
+
})),
|
|
176
|
+
removePending: (key, id) =>
|
|
177
|
+
update(key, state => ({ ...state, pending: state.pending.filter(p => p.id !== id) })),
|
|
178
|
+
clearPending: key => update(key, state => ({ ...state, pending: [] })),
|
|
179
|
+
completePending: (key, submitted, posted) =>
|
|
180
|
+
update(key, state => ({
|
|
181
|
+
...state,
|
|
182
|
+
pending: state.pending.filter(p => !submitted.some(s => s.id === p.id && s.body === p.body)),
|
|
183
|
+
posted: [
|
|
184
|
+
...state.posted,
|
|
185
|
+
...posted
|
|
186
|
+
.filter(p => !state.posted.some(s => s.commentId === p.commentId))
|
|
187
|
+
.map(p => ({ ...p, at: now().toISOString() })),
|
|
188
|
+
],
|
|
189
|
+
})),
|
|
121
190
|
}
|
|
122
191
|
}
|