@vintasoftware/pr-review-canvas 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- 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 +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -21
- 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 +4 -19
- 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 +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -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/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
package/src/chat/threads.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// A chat thread is one acpx session plus the transcript of what was said in it. Both are named
|
|
2
|
-
// after the
|
|
2
|
+
// after the review target, so two repositories never share a session and a name can never point
|
|
3
3
|
// outside the chat directory.
|
|
4
4
|
import { appendFile, mkdir, readFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { type ChatTurn, ChatTurnSchema } from '../contract/chat.js'
|
|
7
|
+
import { keyToString, type ReviewKey } from '../contract/review-key.js'
|
|
7
8
|
import type { Repo } from '../contract/review-artifact.js'
|
|
8
9
|
import type { ChatThread } from '../contract/state.js'
|
|
9
10
|
import { isNotFound } from '../store/atomic-json.js'
|
|
@@ -17,19 +18,19 @@ export function slug(value: string): string {
|
|
|
17
18
|
.slice(0, 40)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function threadName(repo: Repo,
|
|
21
|
-
return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${
|
|
21
|
+
export function threadName(repo: Repo, key: ReviewKey, agent: string, index: number): string {
|
|
22
|
+
return `pr-review-${slug(repo.owner)}-${slug(repo.name)}-${keyToString(key)}-${slug(agent)}-t${index}`
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const NAME_RE = /^pr-review-[a-z0-9-]+-(\d
|
|
25
|
+
const NAME_RE = /^pr-review-[a-z0-9-]+-(\d+|branch|uncommitted)-[a-z0-9-]+-t(\d+)$/
|
|
25
26
|
|
|
26
|
-
/** True when the name is one this tool could have made for this
|
|
27
|
-
export function isThreadNameFor(name: string,
|
|
27
|
+
/** True when the name is one this tool could have made for this target. */
|
|
28
|
+
export function isThreadNameFor(name: string, key: ReviewKey): boolean {
|
|
28
29
|
const m = NAME_RE.exec(name)
|
|
29
|
-
return m !== null && m[1] ===
|
|
30
|
+
return m !== null && m[1] === keyToString(key)
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/** The next `t<k>`: one past the highest index already used on this
|
|
33
|
+
/** The next `t<k>`: one past the highest index already used on this target. */
|
|
33
34
|
export function nextThreadIndex(threads: readonly ChatThread[]): number {
|
|
34
35
|
let highest = 0
|
|
35
36
|
for (const thread of threads) {
|
|
@@ -55,22 +56,22 @@ export function threadTitle(firstMessage: string): string {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export interface TranscriptStore {
|
|
58
|
-
dir(
|
|
59
|
-
file(
|
|
60
|
-
eventsFile(
|
|
61
|
-
read(
|
|
62
|
-
append(
|
|
59
|
+
dir(key: ReviewKey): string
|
|
60
|
+
file(key: ReviewKey, name: string): string
|
|
61
|
+
eventsFile(key: ReviewKey, name: string): string
|
|
62
|
+
read(key: ReviewKey, name: string): Promise<ChatTurn[]>
|
|
63
|
+
append(key: ReviewKey, name: string, turn: ChatTurn): Promise<void>
|
|
63
64
|
/** Appends one already-scrubbed acpx line to the raw event log. */
|
|
64
|
-
appendEvent(
|
|
65
|
+
appendEvent(key: ReviewKey, name: string, line: string): Promise<void>
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
export function createTranscriptStore(prDir: (
|
|
68
|
-
const dir = (
|
|
69
|
-
const named = (
|
|
70
|
-
if (!isThreadNameFor(name,
|
|
71
|
-
throw new Error(`not a chat thread of
|
|
68
|
+
export function createTranscriptStore(prDir: (key: ReviewKey) => string): TranscriptStore {
|
|
69
|
+
const dir = (key: ReviewKey): string => path.join(prDir(key), 'chat')
|
|
70
|
+
const named = (key: ReviewKey, name: string, suffix: string): string => {
|
|
71
|
+
if (!isThreadNameFor(name, key)) {
|
|
72
|
+
throw new Error(`not a chat thread of ${keyToString(key)}: ${name}`)
|
|
72
73
|
}
|
|
73
|
-
return path.join(dir(
|
|
74
|
+
return path.join(dir(key), `${name}${suffix}`)
|
|
74
75
|
}
|
|
75
76
|
const appendLine = async (file: string, line: string): Promise<void> => {
|
|
76
77
|
await mkdir(path.dirname(file), { recursive: true })
|
|
@@ -78,12 +79,12 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
|
|
|
78
79
|
}
|
|
79
80
|
return {
|
|
80
81
|
dir,
|
|
81
|
-
file: (
|
|
82
|
-
eventsFile: (
|
|
83
|
-
async read(
|
|
82
|
+
file: (key, name) => named(key, name, '.jsonl'),
|
|
83
|
+
eventsFile: (key, name) => named(key, name, '.events.ndjson'),
|
|
84
|
+
async read(key, name) {
|
|
84
85
|
let text: string
|
|
85
86
|
try {
|
|
86
|
-
text = await readFile(named(
|
|
87
|
+
text = await readFile(named(key, name, '.jsonl'), 'utf8')
|
|
87
88
|
} catch (err) {
|
|
88
89
|
if (isNotFound(err)) {
|
|
89
90
|
return []
|
|
@@ -108,7 +109,7 @@ export function createTranscriptStore(prDir: (prNumber: number) => string): Tran
|
|
|
108
109
|
}
|
|
109
110
|
return turns
|
|
110
111
|
},
|
|
111
|
-
append: (
|
|
112
|
-
appendEvent: (
|
|
112
|
+
append: (key, name, turn) => appendLine(named(key, name, '.jsonl'), JSON.stringify(turn)),
|
|
113
|
+
appendEvent: (key, name, line) => appendLine(named(key, name, '.events.ndjson'), line),
|
|
113
114
|
}
|
|
114
115
|
}
|
package/src/cli.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { ConfigError, loadRuntimeConfig, parsePort, readEnv, resolveRepoRoot } from './config.js'
|
|
19
19
|
import { type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
20
20
|
import { createGit } from './git/git.js'
|
|
21
|
-
import {
|
|
21
|
+
import { createHostClient } from './host/client.js'
|
|
22
22
|
import { loadProjectConfig } from './project-config.js'
|
|
23
23
|
import { checkSkill } from './review/doctor.js'
|
|
24
24
|
import { type AppContext, createAppContext, readPackageVersion } from './server/context.js'
|
|
@@ -41,7 +41,12 @@ const USAGE = `usage: pr-review <command> [flags]
|
|
|
41
41
|
|
|
42
42
|
serve [--port 3010] [--repo <dir>] [--data-dir <dir>] [--fixture-canvas <review.json>]
|
|
43
43
|
[--agent claude|codex] [--model <id>] (chat only; wins over .pr-review/settings.yml)
|
|
44
|
-
prepare (--pr <n> | --base <ref> --head <ref>) [--force]
|
|
44
|
+
prepare (--pr <n> | --branch | --uncommitted | --base <ref> --head <ref>) [--force]
|
|
45
|
+
[--base <ref>] [--repo <dir>] [--data-dir <dir>]
|
|
46
|
+
(--branch reviews the current branch against the default branch, and
|
|
47
|
+
--uncommitted reviews it with the working tree's edits and new files on top.
|
|
48
|
+
Both are for work with no PR yet, take --base <ref> to compare against
|
|
49
|
+
another branch, and show at /review/branch and /review/uncommitted.)
|
|
45
50
|
validate <model.json|review.json> --canvas <dir> [--human] [--fix] [--repo <dir>] [--data-dir <dir>]
|
|
46
51
|
(--fix trims over-cap titles in place and reports each one)
|
|
47
52
|
publish <canvasDir> --agent <id> [--model <id>] --harness claude-code|codex|other [--allow-stale]
|
|
@@ -52,7 +57,7 @@ const USAGE = `usage: pr-review <command> [flags]
|
|
|
52
57
|
doctor [--all-checks] [--repo <dir>] [--data-dir <dir>]
|
|
53
58
|
|
|
54
59
|
Every command prints one JSON line on success and { "error": { code, message, hint } } on failure.
|
|
55
|
-
Exit codes: 0 ok, 1 error, 2 usage, 4 gh missing or not logged in, 5 invalid model output.
|
|
60
|
+
Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab missing or not logged in, 5 invalid model output.
|
|
56
61
|
`
|
|
57
62
|
|
|
58
63
|
const io: CliIo = {
|
|
@@ -125,15 +130,15 @@ async function serve(argv: string[]): Promise<number> {
|
|
|
125
130
|
return EXIT.ok
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
/** doctor builds no AppContext: it has to answer even when the repo or
|
|
133
|
+
/** doctor builds no AppContext: it has to answer even when the repo or host CLI is the problem. */
|
|
129
134
|
async function doctorCommand(argv: string[]): Promise<number> {
|
|
130
135
|
const { repo, dataDir, rest } = splitCommonFlags(argv)
|
|
131
136
|
const cwd = process.cwd()
|
|
132
|
-
const git = createGit(repo === undefined ? cwd : path.resolve(cwd, repo))
|
|
133
137
|
return runDoctor(
|
|
134
138
|
{
|
|
135
|
-
git,
|
|
136
|
-
|
|
139
|
+
git: createGit(repo === undefined ? cwd : path.resolve(cwd, repo)),
|
|
140
|
+
env: process.env,
|
|
141
|
+
client: host => createHostClient(host.cli),
|
|
137
142
|
version: readPackageVersion(),
|
|
138
143
|
acpxVersion: () => createAgentRunner().acpxVersion(),
|
|
139
144
|
dataDirOverride: dataDir ?? readEnv(process.env, 'PR_REVIEW_DATA_DIR'),
|
package/src/commands.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { exportCanvas } from './canvas/export.js'
|
|
|
7
7
|
import { importCanvas } from './canvas/import.js'
|
|
8
8
|
import { CANVAS_ZIP_MAX_BYTES } from './canvas/zip.js'
|
|
9
9
|
import type { ErrorCode } from './contract/api.js'
|
|
10
|
-
import type { GenerationContext,
|
|
10
|
+
import type { GenerationContext, PrepareTargetInput } from './contract/generation-context.js'
|
|
11
|
+
import type { LocalKey } from './contract/review-key.js'
|
|
11
12
|
import { HARNESSES, type ReviewArtifact, ReviewArtifactSchema } from './contract/review-artifact.js'
|
|
12
13
|
import { formatValidationError, type ValidationReport } from './contract/validation.js'
|
|
13
|
-
import {
|
|
14
|
+
import { fetchPrRefs } from './git/pr-refs.js'
|
|
14
15
|
import { type DoctorDeps, runDoctorChecks } from './review/doctor.js'
|
|
15
16
|
import {
|
|
16
17
|
CLAUDE_SKILLS_DIR,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
import { applyTitleTrims, type TitleTrim } from './review/trim-caps.js'
|
|
33
34
|
import { validateModelOutput } from './review/validate.js'
|
|
34
35
|
import type { AppContext } from './server/context.js'
|
|
35
|
-
import { AppError, toAppError } from './server/errors.js'
|
|
36
|
+
import { AppError, CLI_SETUP_CODES, toAppError } from './server/errors.js'
|
|
36
37
|
import { readText, writeTextAtomic } from './store/atomic-json.js'
|
|
37
38
|
|
|
38
39
|
export interface CliIo {
|
|
@@ -40,7 +41,7 @@ export interface CliIo {
|
|
|
40
41
|
stderr(line: string): void
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Exit codes: 0 ok, 1 error, 2 usage, 4 gh auth or missing, 5 invalid model output. */
|
|
44
|
+
/** Exit codes: 0 ok, 1 error, 2 usage, 4 gh/glab auth or missing, 5 invalid model output. */
|
|
44
45
|
export const EXIT = { ok: 0, error: 1, usage: 2, gh: 4, invalid: 5 } as const
|
|
45
46
|
|
|
46
47
|
export class UsageError extends Error {
|
|
@@ -94,7 +95,7 @@ export function reportFailure(io: CliIo, err: unknown): number {
|
|
|
94
95
|
}
|
|
95
96
|
const appErr = toAppError(err)
|
|
96
97
|
printErrorEnvelope(io, appErr.code, appErr.message, appErr.hint)
|
|
97
|
-
return
|
|
98
|
+
return CLI_SETUP_CODES.has(appErr.code) ? EXIT.gh : EXIT.error
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
@@ -145,17 +146,39 @@ function parsePrNumber(raw: string): number {
|
|
|
145
146
|
return n
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
export
|
|
149
|
+
export interface PrepareFlags {
|
|
150
|
+
pr?: string | undefined
|
|
151
|
+
base?: string | undefined
|
|
152
|
+
head?: string | undefined
|
|
153
|
+
branch?: boolean | undefined
|
|
154
|
+
uncommitted?: boolean | undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LOCAL_FLAGS = 'pass one of --pr <n>, --branch, --uncommitted, or --base <ref> --head <ref>'
|
|
158
|
+
|
|
159
|
+
/** The target the flags name. `prepare` resolves a local review's base against the clone. */
|
|
160
|
+
export function parsePrepareTarget(values: PrepareFlags): PrepareTargetInput {
|
|
161
|
+
const local: LocalKey | undefined =
|
|
162
|
+
values.branch === true ? 'branch' : values.uncommitted === true ? 'uncommitted' : undefined
|
|
163
|
+
if (values.branch === true && values.uncommitted === true) {
|
|
164
|
+
throw new UsageError('--branch and --uncommitted are two reviews; ask for one of them')
|
|
165
|
+
}
|
|
149
166
|
if (values.pr !== undefined) {
|
|
150
|
-
if (values.base !== undefined || values.head !== undefined) {
|
|
151
|
-
throw new UsageError(
|
|
167
|
+
if (values.base !== undefined || values.head !== undefined || local !== undefined) {
|
|
168
|
+
throw new UsageError(LOCAL_FLAGS)
|
|
152
169
|
}
|
|
153
170
|
return { kind: 'pr', number: parsePrNumber(values.pr) }
|
|
154
171
|
}
|
|
172
|
+
if (local !== undefined) {
|
|
173
|
+
if (values.head !== undefined) {
|
|
174
|
+
throw new UsageError(`--${local} reviews this clone, so it takes no --head`)
|
|
175
|
+
}
|
|
176
|
+
return { kind: 'local', source: local, base: values.base }
|
|
177
|
+
}
|
|
155
178
|
if (values.base !== undefined && values.head !== undefined) {
|
|
156
179
|
return { kind: 'refs', base: values.base, head: values.head }
|
|
157
180
|
}
|
|
158
|
-
throw new UsageError(
|
|
181
|
+
throw new UsageError(`prepare needs a target: ${LOCAL_FLAGS}`)
|
|
159
182
|
}
|
|
160
183
|
|
|
161
184
|
export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
@@ -165,12 +188,16 @@ export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
165
188
|
pr: { type: 'string' },
|
|
166
189
|
base: { type: 'string' },
|
|
167
190
|
head: { type: 'string' },
|
|
191
|
+
branch: { type: 'boolean' },
|
|
192
|
+
uncommitted: { type: 'boolean' },
|
|
168
193
|
force: { type: 'boolean' },
|
|
169
194
|
},
|
|
170
195
|
strict: true,
|
|
171
196
|
})
|
|
172
|
-
const
|
|
173
|
-
|
|
197
|
+
const result = await prepare(ctx, parsePrepareTarget(values), {
|
|
198
|
+
force: values.force === true,
|
|
199
|
+
log: phase => io.stderr(phase),
|
|
200
|
+
})
|
|
174
201
|
printJson(io, result)
|
|
175
202
|
return EXIT.ok
|
|
176
203
|
}
|
|
@@ -308,6 +335,8 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
308
335
|
harness: parseHarness(values.harness),
|
|
309
336
|
allowStale: values['allow-stale'] === true,
|
|
310
337
|
})
|
|
338
|
+
if (result.sharing.status === 'failed')
|
|
339
|
+
io.stderr(`${result.sharing.warning} ZIP: ${result.sharing.zipPath}`)
|
|
311
340
|
printJson(io, result)
|
|
312
341
|
return EXIT.ok
|
|
313
342
|
}
|
|
@@ -368,8 +397,8 @@ async function resolveHead(
|
|
|
368
397
|
if (prNumber === undefined) {
|
|
369
398
|
throw new UsageError('export needs --pr <n> or --head <ref|sha>')
|
|
370
399
|
}
|
|
371
|
-
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
372
|
-
const { headSha } = await fetchPrRefs(ctx.git, meta)
|
|
400
|
+
const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
401
|
+
const { headSha } = await fetchPrRefs(ctx.git, ctx.config.host, meta)
|
|
373
402
|
return { headSha, prNumber }
|
|
374
403
|
}
|
|
375
404
|
|
|
@@ -387,7 +416,7 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
387
416
|
out: values.out,
|
|
388
417
|
})
|
|
389
418
|
printJson(io, result)
|
|
390
|
-
io.stderr(`drag ${result.path} into the
|
|
419
|
+
io.stderr(`drag ${result.path} into the ${ctx.config.host.noun} description or a comment`)
|
|
391
420
|
return EXIT.ok
|
|
392
421
|
}
|
|
393
422
|
|
|
@@ -445,9 +474,9 @@ export async function runImport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
445
474
|
const options: Parameters<typeof importCanvas>[1] = { bytes, force: values.force === true }
|
|
446
475
|
if (values.pr !== undefined) {
|
|
447
476
|
const prNumber = parsePrNumber(values.pr)
|
|
448
|
-
const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
477
|
+
const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, prNumber)
|
|
449
478
|
options.prNumber = prNumber
|
|
450
|
-
options.
|
|
479
|
+
options.currentHead = await fetchPrRefs(ctx.git, ctx.config.host, meta)
|
|
451
480
|
}
|
|
452
481
|
printJson(io, await importCanvas(ctx, options))
|
|
453
482
|
return EXIT.ok
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,8 @@ import path from 'node:path'
|
|
|
2
2
|
import type { Repo } from './contract/review-artifact.js'
|
|
3
3
|
import { isChatAgent, type SettingsOverrides } from './contract/settings.js'
|
|
4
4
|
import { type Git, GitError } from './git/git.js'
|
|
5
|
+
import type { Host } from './host/host.js'
|
|
6
|
+
import { type OriginRemote, parseOriginRemote } from './host/remote.js'
|
|
5
7
|
import { resolveDataDir } from './store/data-dir.js'
|
|
6
8
|
|
|
7
9
|
export const DEFAULT_PORT = 3010
|
|
@@ -22,6 +24,8 @@ export interface RuntimeConfig {
|
|
|
22
24
|
commonDir: string
|
|
23
25
|
dataDir: string
|
|
24
26
|
repo: Repo
|
|
27
|
+
/** The forge origin points at, which owns every request or answer that differs between them. */
|
|
28
|
+
host: Host
|
|
25
29
|
/** Dev only: every PR reports `ready` with this artifact re-keyed to the live head. */
|
|
26
30
|
fixtureCanvasPath: string | null
|
|
27
31
|
/** Chat agent and model the flags force for this run, if any. */
|
|
@@ -61,36 +65,19 @@ export async function resolveCommonDir(git: Git): Promise<string> {
|
|
|
61
65
|
return git.commonDir()
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const m =
|
|
67
|
-
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/(?:[^@/]+@)?github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
|
|
68
|
-
url.trim()
|
|
69
|
-
)
|
|
70
|
-
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
return { owner: m[1], name: m[2] }
|
|
74
|
-
}
|
|
68
|
+
export const ORIGIN_HINT =
|
|
69
|
+
'add a github.com or GitLab origin, or set PR_REVIEW_HOST=gitlab for self-hosted GitLab'
|
|
75
70
|
|
|
76
|
-
export async function
|
|
71
|
+
export async function resolveOrigin(git: Git, env: NodeJS.ProcessEnv = {}): Promise<OriginRemote> {
|
|
77
72
|
const url = await git.remoteUrl('origin')
|
|
78
73
|
if (url === null) {
|
|
79
|
-
throw new ConfigError(
|
|
80
|
-
'NO_ORIGIN',
|
|
81
|
-
'the repository has no "origin" remote',
|
|
82
|
-
'add one that points at GitHub'
|
|
83
|
-
)
|
|
74
|
+
throw new ConfigError('NO_ORIGIN', 'the repository has no "origin" remote', ORIGIN_HINT)
|
|
84
75
|
}
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
throw new ConfigError(
|
|
88
|
-
'NO_ORIGIN',
|
|
89
|
-
`origin is not a GitHub URL: ${url}`,
|
|
90
|
-
'only github.com repositories are supported'
|
|
91
|
-
)
|
|
76
|
+
const parsed = parseOriginRemote(url, env)
|
|
77
|
+
if (parsed === null) {
|
|
78
|
+
throw new ConfigError('NO_ORIGIN', `origin is not a GitHub or GitLab URL: ${url}`, ORIGIN_HINT)
|
|
92
79
|
}
|
|
93
|
-
return
|
|
80
|
+
return parsed
|
|
94
81
|
}
|
|
95
82
|
|
|
96
83
|
/** `--agent` names one of the agents the chat knows; anything else is a usage error. */
|
|
@@ -139,7 +126,7 @@ export async function loadRuntimeConfig(
|
|
|
139
126
|
): Promise<RuntimeConfig> {
|
|
140
127
|
const repoRoot = await resolveRepoRoot(git)
|
|
141
128
|
const commonDir = await resolveCommonDir(git)
|
|
142
|
-
const repo = await
|
|
129
|
+
const { repo, host } = await resolveOrigin(git, env)
|
|
143
130
|
const port = flags.port ?? parsePort(readEnv(env, 'PR_REVIEW_PORT'), DEFAULT_PORT)
|
|
144
131
|
const dataDir = resolveDataDir({ override: flags.dataDir ?? readEnv(env, 'PR_REVIEW_DATA_DIR'), commonDir })
|
|
145
132
|
return {
|
|
@@ -148,6 +135,7 @@ export async function loadRuntimeConfig(
|
|
|
148
135
|
commonDir,
|
|
149
136
|
dataDir,
|
|
150
137
|
repo,
|
|
138
|
+
host,
|
|
151
139
|
fixtureCanvasPath: flags.fixtureCanvas === undefined ? null : path.resolve(cwd, flags.fixtureCanvas),
|
|
152
140
|
chatOverrides: parseChatOverrides(flags),
|
|
153
141
|
}
|
package/src/contract/api.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CanvasManifest } from './canvas-manifest.js'
|
|
|
3
3
|
import type { CommentsPayload, IssueComment, ReviewComment } from './comments.js'
|
|
4
4
|
import type { SharedCanvasInfoSchema } from './discovery.js'
|
|
5
5
|
import type { FileEntry, Pr, ReviewArtifact } from './review-artifact.js'
|
|
6
|
+
import type { LocalKey, ReviewKey } from './review-key.js'
|
|
6
7
|
import type { PrState } from './state.js'
|
|
7
8
|
|
|
8
9
|
export const ERROR_CODES = [
|
|
@@ -16,10 +17,14 @@ export const ERROR_CODES = [
|
|
|
16
17
|
'GH_MISSING',
|
|
17
18
|
'GH_UNAUTHENTICATED',
|
|
18
19
|
'GITHUB_API_ERROR',
|
|
20
|
+
'GLAB_MISSING',
|
|
21
|
+
'GLAB_UNAUTHENTICATED',
|
|
22
|
+
'GITLAB_API_ERROR',
|
|
19
23
|
'PR_NOT_FOUND',
|
|
20
24
|
'CANVAS_NOT_FOUND',
|
|
21
25
|
'CANVAS_INVALID',
|
|
22
26
|
'CANVAS_REPO_MISMATCH',
|
|
27
|
+
'CANVAS_PR_MISMATCH',
|
|
23
28
|
'CANVAS_TOO_LARGE',
|
|
24
29
|
'CANVAS_STALE',
|
|
25
30
|
'MODEL_INVALID',
|
|
@@ -65,6 +70,17 @@ export interface StaleInfo {
|
|
|
65
70
|
commitsBehind?: number
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A ready canvas generated for an earlier commit of the pull request whose diff is identical to
|
|
75
|
+
* the head's, so the page keeps it and says so.
|
|
76
|
+
*/
|
|
77
|
+
export interface CarriedOverInfo {
|
|
78
|
+
canvasHeadSha: string
|
|
79
|
+
currentHeadSha: string
|
|
80
|
+
/** How many commits the head is ahead of the canvas's commit; absent when the head does not contain it. */
|
|
81
|
+
commitsBehind?: number
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
/** The answer of `POST /import`, of `pr-review import`, and of an imported shared canvas. */
|
|
69
85
|
export interface ImportResult {
|
|
70
86
|
status: 'ready' | 'stale' | 'exists'
|
|
@@ -88,6 +104,15 @@ export interface SharedCanvasFetchResponse {
|
|
|
88
104
|
/** A canvas zip found on the pull request. Shaped by the schema the discovery cache stores. */
|
|
89
105
|
export type SharedCanvasInfo = z.infer<typeof SharedCanvasInfoSchema>
|
|
90
106
|
|
|
107
|
+
/** The forge the server talks to, as the page and the health endpoint learn it. */
|
|
108
|
+
export interface PublicHost {
|
|
109
|
+
kind: 'github' | 'gitlab'
|
|
110
|
+
/** `GitHub` or `GitLab`, for the words on the page. */
|
|
111
|
+
label: string
|
|
112
|
+
/** `https://github.com` or the GitLab instance, for links to profiles. */
|
|
113
|
+
webBase: string
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
export interface PrBundle {
|
|
92
117
|
status: BundleStatus
|
|
93
118
|
pr: Pr
|
|
@@ -96,8 +121,12 @@ export interface PrBundle {
|
|
|
96
121
|
artifact?: ReviewArtifact
|
|
97
122
|
canvas?: CanvasInfo
|
|
98
123
|
stale?: StaleInfo
|
|
124
|
+
/** Set on a ready bundle whose canvas was generated for another commit with an identical diff. */
|
|
125
|
+
carriedOver?: CarriedOverInfo
|
|
99
126
|
sharedCanvas?: SharedCanvasInfo
|
|
100
127
|
skillCommand: string
|
|
128
|
+
/** Set on a local review: work that has no pull request, so the forge side of the page is off. */
|
|
129
|
+
local?: LocalKey
|
|
101
130
|
comments: CommentsPayload
|
|
102
131
|
state: PrState
|
|
103
132
|
capabilities: Capabilities
|
|
@@ -111,7 +140,8 @@ export interface PrBundle {
|
|
|
111
140
|
|
|
112
141
|
/** Every answer of a state route, so the page can replace its copy of the state in one step. */
|
|
113
142
|
export interface StateResponse {
|
|
114
|
-
|
|
143
|
+
/** The target the state belongs to: a pull request number, or `local`. */
|
|
144
|
+
prNumber: ReviewKey
|
|
115
145
|
state: PrState
|
|
116
146
|
}
|
|
117
147
|
|
|
@@ -172,6 +202,7 @@ export interface HealthResponse {
|
|
|
172
202
|
agentAuth?: HealthCheck
|
|
173
203
|
}
|
|
174
204
|
repo: { owner: string; name: string } | null
|
|
205
|
+
host: PublicHost
|
|
175
206
|
dataDir: string
|
|
176
207
|
chat: ChatStatus
|
|
177
208
|
}
|
|
@@ -23,6 +23,8 @@ export const CanvasIndexSchema = z.object({
|
|
|
23
23
|
generatedAt: z.string(),
|
|
24
24
|
source: z.enum(['local', 'import']),
|
|
25
25
|
importedAt: z.string().optional(),
|
|
26
|
+
/** A snapshot of uncommitted work: it sits on no branch, so no pull request can claim it. */
|
|
27
|
+
worktree: z.boolean().optional(),
|
|
26
28
|
})
|
|
27
29
|
),
|
|
28
30
|
})
|
package/src/contract/comments.ts
CHANGED
|
@@ -96,3 +96,8 @@ export {
|
|
|
96
96
|
export function emptyComments(headSha: string, fetchedAt: string): CommentsPayload {
|
|
97
97
|
return { fetchedAt, headSha, reviewComments: [], issueComments: [] }
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
export interface FetchCommentsResult {
|
|
101
|
+
payload: CommentsPayload
|
|
102
|
+
warnings: string[]
|
|
103
|
+
}
|
|
@@ -3,9 +3,12 @@ import { z } from 'zod'
|
|
|
3
3
|
export const SharedCanvasInfoSchema = z.object({
|
|
4
4
|
url: z.string(),
|
|
5
5
|
name: z.string(),
|
|
6
|
-
|
|
6
|
+
/** The attachment's file name carries the head's sha; it says nothing about whether it is current. */
|
|
7
|
+
namesHead: z.boolean(),
|
|
7
8
|
downloadable: z.boolean(),
|
|
8
|
-
reason: z
|
|
9
|
+
reason: z
|
|
10
|
+
.enum(['auth-required', 'not-zip', 'too-large', 'network', 'name-mismatch', 'pr-mismatch'])
|
|
11
|
+
.optional(),
|
|
9
12
|
})
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { DefaultLayerSchema, GenerationModeSchema, HighRiskRuleSchema } from '../project-config.js'
|
|
3
3
|
import { DEFAULT_TEST_PATTERNS } from '../review/test-paths.js'
|
|
4
|
+
import { type LocalKey, LocalKeySchema } from './review-key.js'
|
|
4
5
|
import { FileEntrySchema, LIMITS, PrSchema, RepoSchema, type TextCaps } from './review-artifact.js'
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* What `prepare` was asked to describe: a pull request, two refs, or one of the two reviews of
|
|
9
|
+
* work in this clone that has no pull request yet. A `local` target names only its base, because
|
|
10
|
+
* its head is whatever the branch or the working tree holds when the command runs.
|
|
11
|
+
*/
|
|
12
|
+
export const LocalPrepareTargetSchema = z.object({
|
|
13
|
+
kind: z.literal('local'),
|
|
14
|
+
base: z.string().min(1),
|
|
15
|
+
/** Which local review this is, which is also the key it is filed and served under. */
|
|
16
|
+
source: LocalKeySchema,
|
|
17
|
+
})
|
|
18
|
+
export type LocalPrepareTarget = z.infer<typeof LocalPrepareTargetSchema>
|
|
19
|
+
|
|
7
20
|
export const PrepareTargetSchema = z.discriminatedUnion('kind', [
|
|
8
21
|
z.object({ kind: z.literal('pr'), number: z.number().int().positive() }),
|
|
9
22
|
z.object({ kind: z.literal('refs'), base: z.string().min(1), head: z.string().min(1) }),
|
|
23
|
+
LocalPrepareTargetSchema,
|
|
10
24
|
])
|
|
11
25
|
export type PrepareTarget = z.infer<typeof PrepareTargetSchema>
|
|
12
26
|
|
|
27
|
+
/**
|
|
28
|
+
* What the CLI parsed, before git has been asked anything. A local review's base may still be
|
|
29
|
+
* open here: only the clone knows which branch `origin/HEAD` points at. `prepare` resolves it and
|
|
30
|
+
* records the answer, so `publish` reads a base that cannot drift.
|
|
31
|
+
*/
|
|
32
|
+
export type PrepareTargetInput =
|
|
33
|
+
| Extract<PrepareTarget, { kind: 'pr' } | { kind: 'refs' }>
|
|
34
|
+
| { kind: 'local'; base?: string | undefined; source: LocalKey }
|
|
35
|
+
|
|
13
36
|
const capsShape = {
|
|
14
37
|
summary: z.number().int().positive(),
|
|
15
38
|
layerTitle: z.number().int().positive(),
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two reviews of work that has no pull request yet. They are separate targets, so reviewing a
|
|
5
|
+
* branch does not disturb the review of what is not committed, and each keeps its own progress
|
|
6
|
+
* marks and chat threads.
|
|
7
|
+
*/
|
|
8
|
+
export const LOCAL_KEYS = ['branch', 'uncommitted'] as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which local review: `branch` is the current branch against the base it will be opened against,
|
|
12
|
+
* uncommitted edits left out; `uncommitted` is that branch with the working tree on top.
|
|
13
|
+
*/
|
|
14
|
+
export type LocalKey = (typeof LOCAL_KEYS)[number]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a canvas, its review state, and its chat threads are filed under: a pull request or merge
|
|
18
|
+
* request number, or one of the local reviews. It is also what the URLs carry, so `/review/12`
|
|
19
|
+
* and `/review/uncommitted` are the same page over different targets.
|
|
20
|
+
*/
|
|
21
|
+
export type ReviewKey = number | LocalKey
|
|
22
|
+
|
|
23
|
+
export const LocalKeySchema = z.enum(LOCAL_KEYS)
|
|
24
|
+
export const ReviewKeySchema = z.union([LocalKeySchema, z.number().int().positive()])
|
|
25
|
+
|
|
26
|
+
export function isLocalKey(key: ReviewKey): key is LocalKey {
|
|
27
|
+
return LOCAL_KEYS.some(local => local === key)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The key as it appears in a URL segment and as a directory name. */
|
|
31
|
+
export function keyToString(key: ReviewKey): string {
|
|
32
|
+
return String(key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the page calls the target, for a title or a heading. */
|
|
36
|
+
export function keyLabel(key: ReviewKey): string {
|
|
37
|
+
if (key === 'branch') {
|
|
38
|
+
return 'Branch review'
|
|
39
|
+
}
|
|
40
|
+
return key === 'uncommitted' ? 'Uncommitted work' : `#${String(key)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The key a URL segment names, or null when it names none of them. */
|
|
44
|
+
export function parseReviewKey(raw: string): ReviewKey | null {
|
|
45
|
+
const local = LocalKeySchema.safeParse(raw)
|
|
46
|
+
if (local.success) {
|
|
47
|
+
return local.data
|
|
48
|
+
}
|
|
49
|
+
const n = Number(raw)
|
|
50
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES'] as const
|
|
4
|
+
export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
|
|
5
|
+
export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
|
|
6
|
+
|
|
7
|
+
export const PostReviewInputSchema = z.object({
|
|
8
|
+
event: ReviewEventSchema,
|
|
9
|
+
/** The dialog sends the body the user read, edited or not. */
|
|
10
|
+
body: z.string().min(1).max(65536).optional(),
|
|
11
|
+
/** The commit the dialog named. The server refuses the review when the head moved on. */
|
|
12
|
+
headSha: z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^[0-9a-f]{40}$/)
|
|
15
|
+
.optional(),
|
|
16
|
+
})
|
|
17
|
+
export type PostReviewInput = z.infer<typeof PostReviewInputSchema>
|
package/src/contract/settings.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface SettingsResponse {
|
|
|
107
107
|
maxRepairRounds: number
|
|
108
108
|
inlineDiffMaxLines: number
|
|
109
109
|
smallPrHunks: number
|
|
110
|
+
/** Whether a canvas still stands for a later head with an identical diff. */
|
|
111
|
+
keepForIdenticalDiff: boolean
|
|
110
112
|
layers: number
|
|
111
113
|
highRisk: number
|
|
112
114
|
}
|