@vintasoftware/pr-review-canvas 0.1.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/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type { CanvasRelation } from '../contract/api.js'
|
|
3
|
+
import {
|
|
4
|
+
type CanvasIndex,
|
|
5
|
+
CanvasIndexSchema,
|
|
6
|
+
type CanvasManifest,
|
|
7
|
+
CanvasManifestSchema,
|
|
8
|
+
} from '../contract/canvas-manifest.js'
|
|
9
|
+
import { type ReviewArtifact, ReviewArtifactSchema } from '../contract/review-artifact.js'
|
|
10
|
+
import type { Git } from '../git/git.js'
|
|
11
|
+
import { readJson, readText, writeJsonAtomic } from './atomic-json.js'
|
|
12
|
+
|
|
13
|
+
export type CanvasLookup =
|
|
14
|
+
| { status: 'ready'; headSha: string }
|
|
15
|
+
| { status: 'stale'; headSha: string; relation: CanvasRelation; commitsBehind?: number }
|
|
16
|
+
| { status: 'missing' }
|
|
17
|
+
|
|
18
|
+
export interface CanvasStore {
|
|
19
|
+
readonly root: string
|
|
20
|
+
canvasDir(headSha: string): string
|
|
21
|
+
readIndex(): Promise<CanvasIndex>
|
|
22
|
+
/** True when a review.json exists for the sha, whatever its format. */
|
|
23
|
+
exists(headSha: string): Promise<boolean>
|
|
24
|
+
readArtifact(headSha: string): Promise<ReviewArtifact | null>
|
|
25
|
+
readManifest(headSha: string): Promise<CanvasManifest | null>
|
|
26
|
+
/** Writes review.json + manifest.json atomically and records the canvas in index.json. */
|
|
27
|
+
write(headSha: string, artifact: ReviewArtifact, manifest: CanvasManifest, prNumber?: number): Promise<void>
|
|
28
|
+
/**
|
|
29
|
+
* The best canvas for this PR: the one written for its current head, else the newest one the
|
|
30
|
+
* head was built on top of, else one from a branch the head no longer contains.
|
|
31
|
+
*/
|
|
32
|
+
findForPr(prNumber: number, currentHeadSha: string): Promise<CanvasLookup>
|
|
33
|
+
/** Records the PR number on a canvas that was exported before the pull request existed. */
|
|
34
|
+
attachPrNumber(headSha: string, prNumber: number): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SHA_RE = /^[0-9a-f]{40}$/
|
|
38
|
+
|
|
39
|
+
export function createCanvasStore(repoRoot: string, git: Git): CanvasStore {
|
|
40
|
+
const indexFile = path.join(repoRoot, 'index.json')
|
|
41
|
+
const canvasDir = (headSha: string): string => {
|
|
42
|
+
if (!SHA_RE.test(headSha)) {
|
|
43
|
+
throw new Error(`not a commit sha: ${headSha}`)
|
|
44
|
+
}
|
|
45
|
+
return path.join(repoRoot, 'canvases', headSha)
|
|
46
|
+
}
|
|
47
|
+
const readIndex = async (): Promise<CanvasIndex> => (await readJson(indexFile, CanvasIndexSchema)) ?? { canvases: {} }
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
root: repoRoot,
|
|
51
|
+
canvasDir,
|
|
52
|
+
readIndex,
|
|
53
|
+
exists: async headSha => (await readText(path.join(canvasDir(headSha), 'review.json'))) !== null,
|
|
54
|
+
readArtifact: headSha => readJson(path.join(canvasDir(headSha), 'review.json'), ReviewArtifactSchema),
|
|
55
|
+
readManifest: headSha => readJson(path.join(canvasDir(headSha), 'manifest.json'), CanvasManifestSchema),
|
|
56
|
+
write: async (headSha, artifact, manifest, prNumber) => {
|
|
57
|
+
const dir = canvasDir(headSha)
|
|
58
|
+
await writeJsonAtomic(path.join(dir, 'review.json'), artifact)
|
|
59
|
+
await writeJsonAtomic(path.join(dir, 'manifest.json'), manifest)
|
|
60
|
+
const index = await readIndex()
|
|
61
|
+
const entry: CanvasIndex['canvases'][string] = { generatedAt: artifact.generatedAt, source: artifact.source }
|
|
62
|
+
const number = prNumber ?? manifest.prNumber
|
|
63
|
+
if (number !== undefined) {
|
|
64
|
+
entry.prNumber = number
|
|
65
|
+
}
|
|
66
|
+
if (artifact.importedAt !== undefined) {
|
|
67
|
+
entry.importedAt = artifact.importedAt
|
|
68
|
+
}
|
|
69
|
+
index.canvases[headSha] = entry
|
|
70
|
+
await writeJsonAtomic(indexFile, index)
|
|
71
|
+
},
|
|
72
|
+
findForPr: async (prNumber, currentHeadSha) => {
|
|
73
|
+
const index = await readIndex()
|
|
74
|
+
if (index.canvases[currentHeadSha] !== undefined) {
|
|
75
|
+
return { status: 'ready', headSha: currentHeadSha }
|
|
76
|
+
}
|
|
77
|
+
// A canvas belongs to this PR when it was exported for it, or before the PR existed.
|
|
78
|
+
const candidates = Object.entries(index.canvases).filter(
|
|
79
|
+
([sha, entry]) => sha !== currentHeadSha && (entry.prNumber === undefined || entry.prNumber === prNumber)
|
|
80
|
+
)
|
|
81
|
+
const ranked: Array<{ headSha: string; generatedAt: string; relation: CanvasRelation; commitsBehind?: number }> =
|
|
82
|
+
[]
|
|
83
|
+
for (const [sha, entry] of candidates) {
|
|
84
|
+
if (await git.isAncestor(sha, currentHeadSha)) {
|
|
85
|
+
ranked.push({
|
|
86
|
+
headSha: sha,
|
|
87
|
+
generatedAt: entry.generatedAt,
|
|
88
|
+
relation: 'ancestor',
|
|
89
|
+
commitsBehind: await git.countCommitsBetween(sha, currentHeadSha),
|
|
90
|
+
})
|
|
91
|
+
} else {
|
|
92
|
+
ranked.push({ headSha: sha, generatedAt: entry.generatedAt, relation: 'unrelated' })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Commits the head was built on come first; a force-pushed-away canvas is the last resort.
|
|
96
|
+
ranked.sort((a, b) => {
|
|
97
|
+
if (a.relation !== b.relation) {
|
|
98
|
+
return a.relation === 'ancestor' ? -1 : 1
|
|
99
|
+
}
|
|
100
|
+
return a.generatedAt < b.generatedAt ? 1 : a.generatedAt > b.generatedAt ? -1 : 0
|
|
101
|
+
})
|
|
102
|
+
const best = ranked[0]
|
|
103
|
+
if (best === undefined) {
|
|
104
|
+
return { status: 'missing' }
|
|
105
|
+
}
|
|
106
|
+
return best.commitsBehind === undefined
|
|
107
|
+
? { status: 'stale', headSha: best.headSha, relation: best.relation }
|
|
108
|
+
: { status: 'stale', headSha: best.headSha, relation: best.relation, commitsBehind: best.commitsBehind }
|
|
109
|
+
},
|
|
110
|
+
attachPrNumber: async (headSha, prNumber) => {
|
|
111
|
+
const index = await readIndex()
|
|
112
|
+
const entry = index.canvases[headSha]
|
|
113
|
+
if (entry === undefined || entry.prNumber === prNumber) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
index.canvases[headSha] = { ...entry, prNumber }
|
|
117
|
+
await writeJsonAtomic(indexFile, index)
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import { readText } from './atomic-json.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `.pr-review/` next to the git common dir, so every worktree of one clone shares canvases, PR
|
|
8
|
+
* state, and settings. The common dir of a worktree is `<main checkout>/.git`, and its parent
|
|
9
|
+
* is the main checkout root. An explicit override wins.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveDataDir(opts: { override?: string | undefined; commonDir: string }): string {
|
|
12
|
+
if (opts.override !== undefined && opts.override !== '') {
|
|
13
|
+
return path.resolve(opts.override)
|
|
14
|
+
}
|
|
15
|
+
return path.join(path.dirname(path.resolve(opts.commonDir)), '.pr-review')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Creates the directory and the `.gitignore` with `*` so it never gets committed. */
|
|
19
|
+
export async function ensureDataDir(dataDir: string): Promise<void> {
|
|
20
|
+
await mkdir(dataDir, { recursive: true })
|
|
21
|
+
const ignore = path.join(dataDir, '.gitignore')
|
|
22
|
+
if ((await readText(ignore)) === null) {
|
|
23
|
+
await writeFile(ignore, '*\n', 'utf8')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function repoDir(dataDir: string, repo: Repo): string {
|
|
28
|
+
return path.join(dataDir, 'repos', `${repo.owner}__${repo.name}`)
|
|
29
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { type FileEntry, FileEntrySchema } from '../contract/review-artifact.js'
|
|
4
|
+
import { collectDiffs, toFileEntry, toPatchMap } from '../git/diff-collector.js'
|
|
5
|
+
import type { Git } from '../git/git.js'
|
|
6
|
+
import { materialize } from '../git/materialize.js'
|
|
7
|
+
import { labelPatch } from '../git/patch-lines.js'
|
|
8
|
+
import { readJson, readText, writeJsonAtomic, writeTextAtomic } from './atomic-json.js'
|
|
9
|
+
import type { CanvasStore } from './canvas-store.js'
|
|
10
|
+
|
|
11
|
+
export interface Derived {
|
|
12
|
+
files: FileEntry[]
|
|
13
|
+
patches: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DerivedMetaSchema = z.object({ headSha: z.string(), mergeBaseSha: z.string(), builtAt: z.string() })
|
|
17
|
+
const FilesSchema = z.array(FileEntrySchema)
|
|
18
|
+
const PatchesSchema = z.record(z.string(), z.string())
|
|
19
|
+
|
|
20
|
+
export interface DerivedStore {
|
|
21
|
+
derivedDir(headSha: string): string
|
|
22
|
+
/** Both commits are local, so `ensure` can rebuild. */
|
|
23
|
+
derivable(headSha: string, mergeBaseSha: string): Promise<boolean>
|
|
24
|
+
/** Reads `derived/` when it matches the commits, otherwise rebuilds it from local git. */
|
|
25
|
+
ensure(headSha: string, mergeBaseSha: string): Promise<Derived>
|
|
26
|
+
read(headSha: string): Promise<Derived | null>
|
|
27
|
+
/** Lines `from..to` (1-based, inclusive) of a materialized file, or null when not materialized. */
|
|
28
|
+
readLines(
|
|
29
|
+
headSha: string,
|
|
30
|
+
side: 'head' | 'base',
|
|
31
|
+
filePath: string,
|
|
32
|
+
from: number,
|
|
33
|
+
to: number
|
|
34
|
+
): Promise<string[] | null>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createDerivedStore(canvases: CanvasStore, git: Git, now: () => Date): DerivedStore {
|
|
38
|
+
const derivedDir = (headSha: string): string => path.join(canvases.canvasDir(headSha), 'derived')
|
|
39
|
+
|
|
40
|
+
const read = async (headSha: string): Promise<Derived | null> => {
|
|
41
|
+
const dir = derivedDir(headSha)
|
|
42
|
+
const files = await readJson(path.join(dir, 'files.json'), FilesSchema)
|
|
43
|
+
const patches = await readJson(path.join(dir, 'patches.json'), PatchesSchema)
|
|
44
|
+
if (files === null || patches === null) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
return { files, patches }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
derivedDir,
|
|
52
|
+
derivable: async (headSha, mergeBaseSha) =>
|
|
53
|
+
(await git.commitExists(headSha)) && (await git.commitExists(mergeBaseSha)),
|
|
54
|
+
ensure: async (headSha, mergeBaseSha) => {
|
|
55
|
+
const dir = derivedDir(headSha)
|
|
56
|
+
const meta = await readJson(path.join(dir, 'meta.json'), DerivedMetaSchema)
|
|
57
|
+
if (meta !== null && meta.mergeBaseSha === mergeBaseSha) {
|
|
58
|
+
const cached = await read(headSha)
|
|
59
|
+
if (cached !== null) {
|
|
60
|
+
return cached
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const collected = await collectDiffs(git, mergeBaseSha, headSha)
|
|
64
|
+
const files = collected.map(toFileEntry)
|
|
65
|
+
const patches = toPatchMap(collected)
|
|
66
|
+
await writeJsonAtomic(path.join(dir, 'files.json'), files)
|
|
67
|
+
await writeJsonAtomic(path.join(dir, 'patches.json'), patches)
|
|
68
|
+
for (const f of collected) {
|
|
69
|
+
await writeTextAtomic(path.join(dir, 'patches', `${f.key}.diff`), `${labelPatch(f.key, f.patch)}\n`)
|
|
70
|
+
}
|
|
71
|
+
await materialize(git, { headSha, mergeBaseSha, files: collected, outDir: dir })
|
|
72
|
+
await writeJsonAtomic(path.join(dir, 'meta.json'), { headSha, mergeBaseSha, builtAt: now().toISOString() })
|
|
73
|
+
return { files, patches }
|
|
74
|
+
},
|
|
75
|
+
read,
|
|
76
|
+
readLines: async (headSha, side, filePath, from, to) => {
|
|
77
|
+
const root = path.join(derivedDir(headSha), side)
|
|
78
|
+
const full = path.resolve(root, filePath)
|
|
79
|
+
if (!full.startsWith(root + path.sep)) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
const text = await readText(full)
|
|
83
|
+
if (text === null) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const lines = text.split('\n')
|
|
87
|
+
if (lines[lines.length - 1] === '') {
|
|
88
|
+
lines.pop()
|
|
89
|
+
}
|
|
90
|
+
return lines.slice(Math.max(from, 1) - 1, Math.max(to, 0))
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { type CommentsPayload, CommentsPayloadSchema } from '../contract/comments.js'
|
|
4
|
+
import { type DiscoveryCache, DiscoveryCacheSchema } from '../contract/discovery.js'
|
|
5
|
+
import { type Pr, PrSchema } from '../contract/review-artifact.js'
|
|
6
|
+
import { readJson, readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
|
|
7
|
+
|
|
8
|
+
/** PR-keyed files: the cached PR meta and the last comments payload. */
|
|
9
|
+
export interface PrStore {
|
|
10
|
+
prDir(number: number): string
|
|
11
|
+
readPr(number: number): Promise<Pr | null>
|
|
12
|
+
writePr(pr: Pr): Promise<void>
|
|
13
|
+
readComments(number: number): Promise<CommentsPayload | null>
|
|
14
|
+
writeComments(number: number, comments: CommentsPayload): Promise<void>
|
|
15
|
+
/** The last attachment scan, so an unchanged PR is not scanned again. */
|
|
16
|
+
readDiscovery(number: number): Promise<DiscoveryCache | null>
|
|
17
|
+
writeDiscovery(number: number, discovery: DiscoveryCache): Promise<void>
|
|
18
|
+
/** PR numbers with a cached pr.json, newest first by file mtime. */
|
|
19
|
+
listRecent(limit: number): Promise<Array<{ number: number; title: string; updatedAt: string }>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createPrStore(repoRoot: string): PrStore {
|
|
23
|
+
const prDir = (number: number): string => {
|
|
24
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
25
|
+
throw new Error(`not a pull request number: ${number}`)
|
|
26
|
+
}
|
|
27
|
+
return path.join(repoRoot, 'prs', String(number))
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
prDir,
|
|
31
|
+
readPr: number => readJson(path.join(prDir(number), 'pr.json'), PrSchema),
|
|
32
|
+
writePr: async pr => {
|
|
33
|
+
if (pr.number === null) {
|
|
34
|
+
throw new Error('cannot cache a pre-PR change set under prs/')
|
|
35
|
+
}
|
|
36
|
+
await writeJsonAtomic(path.join(prDir(pr.number), 'pr.json'), pr)
|
|
37
|
+
},
|
|
38
|
+
readComments: number => readJson(path.join(prDir(number), 'comments.json'), CommentsPayloadSchema),
|
|
39
|
+
writeComments: (number, comments) => writeJsonAtomic(path.join(prDir(number), 'comments.json'), comments),
|
|
40
|
+
readDiscovery: number =>
|
|
41
|
+
readJsonOrDefault(path.join(prDir(number), 'discovery.json'), DiscoveryCacheSchema, () => null),
|
|
42
|
+
writeDiscovery: (number, discovery) => writeJsonAtomic(path.join(prDir(number), 'discovery.json'), discovery),
|
|
43
|
+
listRecent: async limit => {
|
|
44
|
+
let names: string[]
|
|
45
|
+
try {
|
|
46
|
+
names = await readdir(path.join(repoRoot, 'prs'))
|
|
47
|
+
} catch {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
const rows: Array<{ number: number; title: string; updatedAt: string; mtime: number }> = []
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const number = Number(name)
|
|
53
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
const file = path.join(prDir(number), 'pr.json')
|
|
57
|
+
// A directory without pr.json, or one written by an older tool version, is skipped.
|
|
58
|
+
const pr = await readJsonOrDefault(file, PrSchema, () => null)
|
|
59
|
+
if (pr === null) {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
const s = await stat(file)
|
|
63
|
+
rows.push({ number, title: pr.title, updatedAt: s.mtime.toISOString(), mtime: s.mtimeMs })
|
|
64
|
+
}
|
|
65
|
+
rows.sort((a, b) => b.mtime - a.mtime)
|
|
66
|
+
return rows.slice(0, limit).map(({ number, title, updatedAt }) => ({ number, title, updatedAt }))
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { isMap, parseDocument } from 'yaml'
|
|
3
|
+
import { DEFAULT_SETTINGS, type Settings, type SettingsInput, SettingsSchema } from '../contract/settings.js'
|
|
4
|
+
import { readText, writeTextAtomic } from './atomic-json.js'
|
|
5
|
+
|
|
6
|
+
export const SETTINGS_FILE = 'settings.yml'
|
|
7
|
+
|
|
8
|
+
/** Written once, the first time the file is needed, so the user can edit it by hand too. */
|
|
9
|
+
export const SETTINGS_TEMPLATE = `# Personal pr-review settings. Gitignored: this file is yours, not the project's.
|
|
10
|
+
# Project-wide settings (layers, caps, chat.enabled) live in pr-review.config.yml at the repo root.
|
|
11
|
+
version: 1
|
|
12
|
+
|
|
13
|
+
# The look of the page: terminal or github.
|
|
14
|
+
skin: terminal
|
|
15
|
+
|
|
16
|
+
# Light or dark: auto follows the operating system, light and dark pin one.
|
|
17
|
+
theme: auto
|
|
18
|
+
|
|
19
|
+
# Which agent answers in the AI Chat pane: claude or codex.
|
|
20
|
+
agent: claude
|
|
21
|
+
|
|
22
|
+
# Model id for that agent, or null for the agent's own default.
|
|
23
|
+
model: null
|
|
24
|
+
|
|
25
|
+
# How long one chat turn may take, in seconds.
|
|
26
|
+
chatTimeoutSec: 600
|
|
27
|
+
|
|
28
|
+
# Cap on agent turns per message, or null for the agent's own default.
|
|
29
|
+
maxTurns: null
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
export interface SettingsStore {
|
|
33
|
+
/** The file's path, shown in the settings dialog. */
|
|
34
|
+
file: string
|
|
35
|
+
/** The saved settings, or the defaults when the file is missing or unreadable. */
|
|
36
|
+
read(): Promise<Settings>
|
|
37
|
+
/**
|
|
38
|
+
* Writes the changed fields back. Comments and key order in the file survive, because the
|
|
39
|
+
* document is edited rather than re-serialized from a plain object.
|
|
40
|
+
*/
|
|
41
|
+
write(input: SettingsInput): Promise<Settings>
|
|
42
|
+
/** Writes the commented template when the file does not exist yet. */
|
|
43
|
+
ensureFile(): Promise<boolean>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createSettingsStore(dataDir: string): SettingsStore {
|
|
47
|
+
const file = path.join(dataDir, SETTINGS_FILE)
|
|
48
|
+
|
|
49
|
+
const read = async (): Promise<Settings> => {
|
|
50
|
+
const text = await readText(file)
|
|
51
|
+
if (text === null) {
|
|
52
|
+
return DEFAULT_SETTINGS
|
|
53
|
+
}
|
|
54
|
+
return parseSettings(text)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every write runs after the one before it, so two that arrive together both land and the
|
|
59
|
+
* first run's template never lands on top of a save.
|
|
60
|
+
*/
|
|
61
|
+
let chain: Promise<unknown> = Promise.resolve()
|
|
62
|
+
const queued = <T>(task: () => Promise<T>): Promise<T> => {
|
|
63
|
+
const chained = chain.then(task, task)
|
|
64
|
+
chain = chained.catch(() => undefined)
|
|
65
|
+
return chained
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const write = async (input: SettingsInput): Promise<Settings> => {
|
|
69
|
+
const current = (await readText(file)) ?? SETTINGS_TEMPLATE
|
|
70
|
+
const next = applySettings(current, input)
|
|
71
|
+
await writeTextAtomic(file, next.text)
|
|
72
|
+
return next.settings
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ensureFile = async (): Promise<boolean> => {
|
|
76
|
+
if ((await readText(file)) !== null) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
await writeTextAtomic(file, SETTINGS_TEMPLATE)
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
file,
|
|
85
|
+
read,
|
|
86
|
+
ensureFile: () => queued(ensureFile),
|
|
87
|
+
write: input => queued(() => write(input)),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The settings a file holds. A file that is not YAML, or whose values do not fit, falls back to
|
|
93
|
+
* the defaults rather than failing the page: the dialog can then write a good file over it.
|
|
94
|
+
*/
|
|
95
|
+
export function parseSettings(text: string): Settings {
|
|
96
|
+
let raw: unknown
|
|
97
|
+
try {
|
|
98
|
+
raw = parseDocument(text).toJS() as unknown
|
|
99
|
+
} catch {
|
|
100
|
+
return DEFAULT_SETTINGS
|
|
101
|
+
}
|
|
102
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
103
|
+
return DEFAULT_SETTINGS
|
|
104
|
+
}
|
|
105
|
+
const merged = { ...DEFAULT_SETTINGS, ...raw, version: 1 }
|
|
106
|
+
const parsed = SettingsSchema.safeParse(merged)
|
|
107
|
+
return parsed.success ? parsed.data : DEFAULT_SETTINGS
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The new file text and the settings it holds, given the old text and the changed fields. */
|
|
111
|
+
export function applySettings(text: string, input: SettingsInput): { text: string; settings: Settings } {
|
|
112
|
+
const wanted = { ...parseSettings(text), ...stripUndefined(input) }
|
|
113
|
+
const parsed = SettingsSchema.safeParse({ ...wanted, version: 1 })
|
|
114
|
+
const settings = parsed.success ? parsed.data : DEFAULT_SETTINGS
|
|
115
|
+
let doc = parseDocument(text)
|
|
116
|
+
// Only a mapping can hold these keys; a file that is a list or a scalar starts over from the
|
|
117
|
+
// template, which is also what a file that does not parse gets.
|
|
118
|
+
if (doc.errors.length > 0 || !isMap(doc.contents)) {
|
|
119
|
+
doc = parseDocument(SETTINGS_TEMPLATE)
|
|
120
|
+
}
|
|
121
|
+
doc.set('version', 1)
|
|
122
|
+
doc.set('skin', settings.skin)
|
|
123
|
+
doc.set('theme', settings.theme)
|
|
124
|
+
doc.set('agent', settings.agent)
|
|
125
|
+
doc.set('model', settings.model)
|
|
126
|
+
doc.set('chatTimeoutSec', settings.chatTimeoutSec)
|
|
127
|
+
doc.set('maxTurns', settings.maxTurns)
|
|
128
|
+
return { text: String(doc), settings }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stripUndefined(input: SettingsInput): Partial<Settings> {
|
|
132
|
+
const out: Partial<Settings> = {}
|
|
133
|
+
if (input.skin !== undefined) {
|
|
134
|
+
out.skin = input.skin
|
|
135
|
+
}
|
|
136
|
+
if (input.theme !== undefined) {
|
|
137
|
+
out.theme = input.theme
|
|
138
|
+
}
|
|
139
|
+
if (input.agent !== undefined) {
|
|
140
|
+
out.agent = input.agent
|
|
141
|
+
}
|
|
142
|
+
if (input.model !== undefined) {
|
|
143
|
+
out.model = input.model === '' ? null : input.model
|
|
144
|
+
}
|
|
145
|
+
if (input.chatTimeoutSec !== undefined) {
|
|
146
|
+
out.chatTimeoutSec = input.chatTimeoutSec
|
|
147
|
+
}
|
|
148
|
+
if (input.maxTurns !== undefined) {
|
|
149
|
+
out.maxTurns = input.maxTurns
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { emptyState, type PrState, PrStateSchema } from '../contract/state.js'
|
|
3
|
+
import { readJsonOrDefault, writeJsonAtomic } from './atomic-json.js'
|
|
4
|
+
import type { PrStore } from './pr-store.js'
|
|
5
|
+
|
|
6
|
+
/** What the canvas posted to GitHub, so the sign-off body can list it. */
|
|
7
|
+
export interface PostedEntry {
|
|
8
|
+
commentId: number
|
|
9
|
+
pointFingerprint?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Per-PR local state: reviewed cards, dismissed points, hidden threads, posted comments. */
|
|
13
|
+
export interface StateStore {
|
|
14
|
+
/** Defaults when the file is missing or does not match the schema (an older tool version wrote it). */
|
|
15
|
+
read(number: number): Promise<PrState>
|
|
16
|
+
/**
|
|
17
|
+
* Read, change, write. Calls for the same PR run one after another, so two requests that
|
|
18
|
+
* arrive together both land instead of one overwriting the other.
|
|
19
|
+
*/
|
|
20
|
+
update(number: number, mutate: (state: PrState) => PrState): Promise<PrState>
|
|
21
|
+
/**
|
|
22
|
+
* Marks one layer or file. `headSha` is the commit the page was showing: when it differs from
|
|
23
|
+
* the one the marks describe, the old marks are dropped, because they were about other code.
|
|
24
|
+
*/
|
|
25
|
+
setReviewed(number: number, id: string, reviewed: boolean, headSha?: string): Promise<PrState>
|
|
26
|
+
setDismissed(number: number, fingerprint: string, dismissed: boolean, reason?: string): Promise<PrState>
|
|
27
|
+
setThreadHidden(number: number, rootCommentId: number, hidden: boolean): Promise<PrState>
|
|
28
|
+
addPosted(number: number, entry: PostedEntry): Promise<PrState>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `layer:<id>` or `layer:<id>/file:<key>`, with the ids and keys the artifact uses. */
|
|
32
|
+
const REVIEWED_ID_RE = /^layer:[A-Za-z0-9_-]+(?:\/file:[A-Za-z0-9_]+)?$/
|
|
33
|
+
|
|
34
|
+
export function isReviewedId(id: string): boolean {
|
|
35
|
+
return REVIEWED_ID_RE.test(id)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `layer:<id>` for both a layer id and one of its file ids. */
|
|
39
|
+
function layerOf(id: string): string {
|
|
40
|
+
const at = id.indexOf('/file:')
|
|
41
|
+
return at === -1 ? id : id.slice(0, at)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createStateStore(prs: PrStore, now: () => Date): StateStore {
|
|
45
|
+
const file = (number: number): string => path.join(prs.prDir(number), 'state.json')
|
|
46
|
+
const read = (number: number): Promise<PrState> =>
|
|
47
|
+
readJsonOrDefault(file(number), PrStateSchema, () => emptyState(now().toISOString()))
|
|
48
|
+
|
|
49
|
+
/** One chain per PR number; each update waits for the one before it. */
|
|
50
|
+
const chains = new Map<number, Promise<unknown>>()
|
|
51
|
+
|
|
52
|
+
const update = (number: number, mutate: (state: PrState) => PrState): Promise<PrState> => {
|
|
53
|
+
const run = async (): Promise<PrState> => {
|
|
54
|
+
const current = await read(number)
|
|
55
|
+
// Every write counts up, so the page can tell which of two answers was written later.
|
|
56
|
+
const next = { ...mutate(current), rev: (current.rev ?? 0) + 1, updatedAt: now().toISOString() }
|
|
57
|
+
await writeJsonAtomic(file(number), next)
|
|
58
|
+
return next
|
|
59
|
+
}
|
|
60
|
+
const chained = (chains.get(number) ?? Promise.resolve()).then(run, run)
|
|
61
|
+
// The chain only orders the calls, so a failed update never blocks the next one.
|
|
62
|
+
chains.set(
|
|
63
|
+
number,
|
|
64
|
+
chained.catch(() => undefined)
|
|
65
|
+
)
|
|
66
|
+
return chained
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
read,
|
|
71
|
+
update,
|
|
72
|
+
setReviewed: (number, id, reviewed, headSha) =>
|
|
73
|
+
update(number, state => {
|
|
74
|
+
const sameHead =
|
|
75
|
+
headSha === undefined || state.reviewedHeadSha === undefined || state.reviewedHeadSha === headSha
|
|
76
|
+
const next = sameHead ? { ...state.reviewed } : {}
|
|
77
|
+
if (reviewed) {
|
|
78
|
+
next[id] = true
|
|
79
|
+
} else {
|
|
80
|
+
for (const key of Object.keys(next)) {
|
|
81
|
+
// Reopening a layer reopens its files, and reopening a file reopens its layer:
|
|
82
|
+
// both readings of "reviewed" have to agree.
|
|
83
|
+
if (key === id || key.startsWith(`${id}/`) || key === layerOf(id)) {
|
|
84
|
+
delete next[key]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return headSha === undefined
|
|
89
|
+
? { ...state, reviewed: next }
|
|
90
|
+
: { ...state, reviewed: next, reviewedHeadSha: headSha }
|
|
91
|
+
}),
|
|
92
|
+
setDismissed: (number, fingerprint, dismissed, reason) =>
|
|
93
|
+
update(number, state => {
|
|
94
|
+
const next = { ...state.dismissed }
|
|
95
|
+
if (dismissed) {
|
|
96
|
+
next[fingerprint] = reason === undefined ? { at: now().toISOString() } : { at: now().toISOString(), reason }
|
|
97
|
+
} else {
|
|
98
|
+
delete next[fingerprint]
|
|
99
|
+
}
|
|
100
|
+
return { ...state, dismissed: next }
|
|
101
|
+
}),
|
|
102
|
+
setThreadHidden: (number, rootCommentId, hidden) =>
|
|
103
|
+
update(number, state => {
|
|
104
|
+
const next = { ...state.hiddenThreads }
|
|
105
|
+
if (hidden) {
|
|
106
|
+
next[String(rootCommentId)] = { at: now().toISOString() }
|
|
107
|
+
} else {
|
|
108
|
+
delete next[String(rootCommentId)]
|
|
109
|
+
}
|
|
110
|
+
return { ...state, hiddenThreads: next }
|
|
111
|
+
}),
|
|
112
|
+
addPosted: (number, entry) =>
|
|
113
|
+
update(number, state => {
|
|
114
|
+
if (state.posted.some(p => p.commentId === entry.commentId)) {
|
|
115
|
+
return state
|
|
116
|
+
}
|
|
117
|
+
const posted = { ...entry, at: now().toISOString() }
|
|
118
|
+
return { ...state, posted: [...state.posted, posted] }
|
|
119
|
+
}),
|
|
120
|
+
}
|
|
121
|
+
}
|