@vintasoftware/pr-review-canvas 0.2.0 → 0.3.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 +9 -3
- package/docs/reference.md +99 -99
- package/package.json +12 -3
- package/pr-review.config.example.yml +4 -4
- package/skills/pr-review-canvas/SKILL.md +8 -1
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/export.ts +11 -2
- package/src/canvas/zip.ts +4 -1
- package/src/chat/chat-manager.ts +16 -3
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/cli.ts +12 -2
- package/src/commands.ts +41 -9
- package/src/config.ts +15 -3
- package/src/contract/comments.ts +3 -1
- package/src/contract/generation-context.ts +3 -1
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/state.ts +3 -1
- package/src/git/diff-collector.ts +2 -1
- package/src/git/git.ts +9 -4
- package/src/github/capabilities.ts +5 -1
- package/src/github/comments.ts +6 -3
- package/src/github/gh.ts +18 -3
- package/src/github/post-comment.ts +4 -1
- package/src/github/pr.ts +4 -1
- package/src/github/threads.ts +5 -1
- package/src/project-config.ts +12 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/doctor.ts +9 -2
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +24 -8
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +15 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate.ts +65 -13
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +17 -4
- package/src/server/context.ts +14 -2
- package/src/server/errors.ts +26 -2
- package/src/server/html.ts +2 -2
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +29 -5
- package/src/server/routes/chat-routes.ts +30 -9
- package/src/server/routes/pages.ts +3 -1
- package/src/server/routes/review-routes.ts +19 -4
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +20 -6
- package/src/store/derived-store.ts +5 -1
- package/src/store/pr-store.ts +2 -1
- package/src/store/state-store.ts +2 -1
- package/static/brand.svg +19 -0
- package/static/js/api.js +15 -3
- package/static/js/app.js +8 -2
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +38 -15
- package/static/js/composer.js +6 -3
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +6 -1
- package/static/js/dom.js +5 -7
- package/static/js/empty-state.js +3 -1
- package/static/js/header.js +1 -1
- package/static/js/interactions.js +55 -15
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +23 -11
- package/static/js/links.js +8 -2
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +3 -2
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/signoff.js +6 -2
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +4 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/responsive.css +1 -15
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +93 -100
- package/static/styles.css +13 -12
package/src/commands.ts
CHANGED
|
@@ -63,7 +63,12 @@ export function printErrorEnvelope(io: CliIo, code: ErrorCode, message: string,
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function isParseArgsError(err: unknown): err is Error {
|
|
66
|
-
return
|
|
66
|
+
return (
|
|
67
|
+
err instanceof Error &&
|
|
68
|
+
'code' in err &&
|
|
69
|
+
typeof err.code === 'string' &&
|
|
70
|
+
err.code.startsWith('ERR_PARSE_ARGS')
|
|
71
|
+
)
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** Prints the envelope for any failure and picks the exit code. */
|
|
@@ -156,7 +161,12 @@ export function parsePrepareTarget(values: { pr?: string; base?: string; head?:
|
|
|
156
161
|
export async function runPrepare(ctx: AppContext, argv: string[], io: CliIo): Promise<number> {
|
|
157
162
|
const { values } = parseArgs({
|
|
158
163
|
args: argv,
|
|
159
|
-
options: {
|
|
164
|
+
options: {
|
|
165
|
+
pr: { type: 'string' },
|
|
166
|
+
base: { type: 'string' },
|
|
167
|
+
head: { type: 'string' },
|
|
168
|
+
force: { type: 'boolean' },
|
|
169
|
+
},
|
|
160
170
|
strict: true,
|
|
161
171
|
})
|
|
162
172
|
const target = parsePrepareTarget(values)
|
|
@@ -194,7 +204,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
194
204
|
})
|
|
195
205
|
const file = positionals[0]
|
|
196
206
|
if (file === undefined || positionals.length > 1) {
|
|
197
|
-
throw new UsageError(
|
|
207
|
+
throw new UsageError(
|
|
208
|
+
'validate takes one file: pr-review validate <model.json|review.json> --canvas <dir>'
|
|
209
|
+
)
|
|
198
210
|
}
|
|
199
211
|
if (values.canvas === undefined) {
|
|
200
212
|
throw new UsageError('validate needs --canvas <dir> (the directory prepare printed)')
|
|
@@ -202,7 +214,11 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
202
214
|
const context = await readContext(path.resolve(values.canvas))
|
|
203
215
|
const text = await readText(path.resolve(file))
|
|
204
216
|
if (text === null) {
|
|
205
|
-
throw new PublishError(
|
|
217
|
+
throw new PublishError(
|
|
218
|
+
'NOT_FOUND',
|
|
219
|
+
`${file} does not exist`,
|
|
220
|
+
'pass the model.json or review.json to check'
|
|
221
|
+
)
|
|
206
222
|
}
|
|
207
223
|
const fixed = values.fix === true ? await fixTitles(path.resolve(file), text, context) : { text, trims: [] }
|
|
208
224
|
const report = await validateFile(ctx, parseModelText(fixed.text, path.basename(file)), context)
|
|
@@ -214,7 +230,9 @@ export async function runValidate(ctx: AppContext, argv: string[], io: CliIo): P
|
|
|
214
230
|
if (trim.outcome === 'fixed') {
|
|
215
231
|
io.stdout(`fixed ${trim.where}: "${trim.from}" -> "${trim.to}"`)
|
|
216
232
|
} else {
|
|
217
|
-
io.stdout(
|
|
233
|
+
io.stdout(
|
|
234
|
+
`unfixable ${trim.where}: ${trim.length} visible chars, cap ${trim.cap}, ${trim.reason}; rewrite by hand`
|
|
235
|
+
)
|
|
218
236
|
}
|
|
219
237
|
}
|
|
220
238
|
if (report.ok) {
|
|
@@ -277,7 +295,9 @@ export async function runPublish(ctx: AppContext, argv: string[], io: CliIo): Pr
|
|
|
277
295
|
})
|
|
278
296
|
const canvasDir = positionals[0]
|
|
279
297
|
if (canvasDir === undefined || positionals.length > 1) {
|
|
280
|
-
throw new UsageError(
|
|
298
|
+
throw new UsageError(
|
|
299
|
+
'publish takes one directory: pr-review publish <canvasDir> --agent <id> --harness <id>'
|
|
300
|
+
)
|
|
281
301
|
}
|
|
282
302
|
if (values.agent === undefined || values.agent === '') {
|
|
283
303
|
throw new UsageError('publish needs --agent <id>')
|
|
@@ -312,7 +332,11 @@ export interface InstallSkillEnv {
|
|
|
312
332
|
export async function runInstallSkill(env: InstallSkillEnv, argv: string[], io: CliIo): Promise<number> {
|
|
313
333
|
const { values } = parseArgs({
|
|
314
334
|
args: argv,
|
|
315
|
-
options: {
|
|
335
|
+
options: {
|
|
336
|
+
'claude-dir': { type: 'string' },
|
|
337
|
+
'codex-dir': { type: 'string' },
|
|
338
|
+
force: { type: 'boolean' },
|
|
339
|
+
},
|
|
316
340
|
strict: true,
|
|
317
341
|
})
|
|
318
342
|
const resolve = (flag: string | undefined, fallback: string): string =>
|
|
@@ -357,7 +381,11 @@ export async function runExport(ctx: AppContext, argv: string[], io: CliIo): Pro
|
|
|
357
381
|
strict: true,
|
|
358
382
|
})
|
|
359
383
|
const target = await resolveHead(ctx, values)
|
|
360
|
-
const result = await exportCanvas(ctx, {
|
|
384
|
+
const result = await exportCanvas(ctx, {
|
|
385
|
+
headSha: target.headSha,
|
|
386
|
+
prNumber: target.prNumber,
|
|
387
|
+
out: values.out,
|
|
388
|
+
})
|
|
361
389
|
printJson(io, result)
|
|
362
390
|
io.stderr(`drag ${result.path} into the pull request description or a comment`)
|
|
363
391
|
return EXIT.ok
|
|
@@ -375,7 +403,11 @@ async function readZipFile(zipPath: string, shown: string): Promise<Uint8Array>
|
|
|
375
403
|
} catch {
|
|
376
404
|
throw new PublishError('NOT_FOUND', `${shown} does not exist`, 'pass the canvas zip to import')
|
|
377
405
|
}
|
|
378
|
-
const tooLarge = new AppError(
|
|
406
|
+
const tooLarge = new AppError(
|
|
407
|
+
'CANVAS_TOO_LARGE',
|
|
408
|
+
`${shown} is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`,
|
|
409
|
+
413
|
|
410
|
+
)
|
|
379
411
|
try {
|
|
380
412
|
// One byte past the cap is read, so a file of exactly the cap still fits and anything longer
|
|
381
413
|
// is refused without the rest of it ever being in memory.
|
package/src/config.ts
CHANGED
|
@@ -47,7 +47,11 @@ export async function resolveRepoRoot(git: Git): Promise<string> {
|
|
|
47
47
|
return await git.topLevel()
|
|
48
48
|
} catch (err) {
|
|
49
49
|
if (err instanceof GitError) {
|
|
50
|
-
throw new ConfigError(
|
|
50
|
+
throw new ConfigError(
|
|
51
|
+
'NOT_A_REPO',
|
|
52
|
+
'not inside a git repository',
|
|
53
|
+
'run from a clone or pass --repo <dir>'
|
|
54
|
+
)
|
|
51
55
|
}
|
|
52
56
|
throw err
|
|
53
57
|
}
|
|
@@ -72,7 +76,11 @@ export function parseGithubRemote(url: string): Repo | null {
|
|
|
72
76
|
export async function resolveGithubRepo(git: Git): Promise<Repo> {
|
|
73
77
|
const url = await git.remoteUrl('origin')
|
|
74
78
|
if (url === null) {
|
|
75
|
-
throw new ConfigError(
|
|
79
|
+
throw new ConfigError(
|
|
80
|
+
'NO_ORIGIN',
|
|
81
|
+
'the repository has no "origin" remote',
|
|
82
|
+
'add one that points at GitHub'
|
|
83
|
+
)
|
|
76
84
|
}
|
|
77
85
|
const repo = parseGithubRemote(url)
|
|
78
86
|
if (repo === null) {
|
|
@@ -90,7 +98,11 @@ export function parseChatOverrides(flags: ServeFlags): SettingsOverrides {
|
|
|
90
98
|
const overrides: SettingsOverrides = {}
|
|
91
99
|
if (flags.agent !== undefined) {
|
|
92
100
|
if (!isChatAgent(flags.agent)) {
|
|
93
|
-
throw new ConfigError(
|
|
101
|
+
throw new ConfigError(
|
|
102
|
+
'BAD_REQUEST',
|
|
103
|
+
`unknown chat agent: ${flags.agent}`,
|
|
104
|
+
'use --agent claude or --agent codex'
|
|
105
|
+
)
|
|
94
106
|
}
|
|
95
107
|
overrides.agent = flags.agent
|
|
96
108
|
}
|
package/src/contract/comments.ts
CHANGED
|
@@ -79,7 +79,9 @@ export const PostCommentInputSchema = z.discriminatedUnion('kind', [
|
|
|
79
79
|
export type PostCommentInput = z.infer<typeof PostCommentInputSchema>
|
|
80
80
|
|
|
81
81
|
/** The posted comment: a review comment for inline and reply, an issue comment for PR-level. */
|
|
82
|
-
export type PostCommentResult =
|
|
82
|
+
export type PostCommentResult =
|
|
83
|
+
| { kind: 'review'; comment: ReviewComment }
|
|
84
|
+
| { kind: 'issue'; comment: IssueComment }
|
|
83
85
|
|
|
84
86
|
// The proposed-comment block a chat answer can carry. The parser lives in
|
|
85
87
|
// static/js/proposed-comment.js so the browser loads the same code without a bundler.
|
|
@@ -57,7 +57,9 @@ export const GenerationContextSchema = z.object({
|
|
|
57
57
|
}),
|
|
58
58
|
/** The globs that make a file a test here. Defaulted, so a context written before this
|
|
59
59
|
* field existed still reads. */
|
|
60
|
-
tests: z
|
|
60
|
+
tests: z
|
|
61
|
+
.object({ patterns: z.array(z.string().min(1)) })
|
|
62
|
+
.default(() => ({ patterns: [...DEFAULT_TEST_PATTERNS] })),
|
|
61
63
|
/** At most `generation.smallPrHunks` hunks: one layer unless concerns differ, fewer annotations. */
|
|
62
64
|
smallPr: z.boolean(),
|
|
63
65
|
/** More than 400 files or 50 000 changed lines: the prompt inlines nothing and tightens the caps. */
|
|
@@ -48,7 +48,9 @@ function mapCaps(fn: (key: keyof TextCaps) => number): TextCaps {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** The project config may raise or lower a cap; the result is what the validator and the prompt use. */
|
|
51
|
-
export function effectiveCaps(
|
|
51
|
+
export function effectiveCaps(
|
|
52
|
+
overrides: { [K in keyof TextCaps]?: number | undefined } | undefined
|
|
53
|
+
): TextCaps {
|
|
52
54
|
return mapCaps(key => overrides?.[key] ?? TEXT_CAPS[key])
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -162,13 +164,17 @@ function text(caps: Caps, key: keyof TextCaps): z.ZodString {
|
|
|
162
164
|
|
|
163
165
|
function textOrEmpty(caps: Caps, key: keyof TextCaps): z.ZodString {
|
|
164
166
|
const visibleCap = caps[key] / HARD_CAP_FACTOR
|
|
165
|
-
const diagrams =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
const diagrams =
|
|
168
|
+
key === 'summary' || key === 'rationale'
|
|
169
|
+
? ' Mermaid fences count toward the separate diagram cap, not this prose cap.'
|
|
170
|
+
: ''
|
|
171
|
+
return z
|
|
172
|
+
.string()
|
|
173
|
+
.max(caps[key])
|
|
174
|
+
.meta({
|
|
175
|
+
description: `At most ${visibleCap} visible characters. Link targets, backticks, and code-fence lines do not count. maxLength is only the raw Markdown ceiling.${diagrams}`,
|
|
176
|
+
'x-visibleMaxLength': visibleCap,
|
|
177
|
+
})
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
export function testEntrySchema(caps: Caps) {
|
package/src/contract/state.ts
CHANGED
|
@@ -25,7 +25,9 @@ export const PrStateSchema = z.object({
|
|
|
25
25
|
*/
|
|
26
26
|
reviewedHeadSha: z.string().optional(),
|
|
27
27
|
hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
|
|
28
|
-
posted: z.array(
|
|
28
|
+
posted: z.array(
|
|
29
|
+
z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
|
|
30
|
+
),
|
|
29
31
|
dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
|
|
30
32
|
chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
|
|
31
33
|
updatedAt: z.string(),
|
|
@@ -79,7 +79,8 @@ export function parseBlock(lines: string[]): Omit<CollectedFile, 'key'> | null {
|
|
|
79
79
|
return null
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const isBinary =
|
|
82
|
+
const isBinary =
|
|
83
|
+
header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
|
|
83
84
|
let status: FileStatus
|
|
84
85
|
if (renameFrom !== null) {
|
|
85
86
|
status = 'renamed'
|
package/src/git/git.ts
CHANGED
|
@@ -62,10 +62,15 @@ interface ExecResult {
|
|
|
62
62
|
/** Runs git with an argument array; never a shell. */
|
|
63
63
|
export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
|
|
64
64
|
return new Promise(resolve => {
|
|
65
|
-
execFile(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
execFile(
|
|
66
|
+
'git',
|
|
67
|
+
args,
|
|
68
|
+
{ cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
|
|
69
|
+
(error, stdout, stderr) => {
|
|
70
|
+
const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
|
|
71
|
+
resolve({ stdout, stderr: stderr.toString('utf8'), code })
|
|
72
|
+
}
|
|
73
|
+
)
|
|
69
74
|
})
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -10,7 +10,11 @@ const RepoBodySchema = z.object({
|
|
|
10
10
|
})
|
|
11
11
|
|
|
12
12
|
/** What a page assumes before the probe answers: posting is tried and GitHub decides. */
|
|
13
|
-
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
13
|
+
export const UNKNOWN_CAPABILITIES: Capabilities = {
|
|
14
|
+
canComment: 'unknown',
|
|
15
|
+
tokenKind: 'unprobed',
|
|
16
|
+
login: null,
|
|
17
|
+
}
|
|
14
18
|
|
|
15
19
|
export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
|
|
16
20
|
|
package/src/github/comments.ts
CHANGED
|
@@ -110,9 +110,12 @@ export async function fetchComments(
|
|
|
110
110
|
fetchAllPages(gh, `${base}/issues/${number}/comments`),
|
|
111
111
|
])
|
|
112
112
|
const reviews = (await fetchAllPages(gh, `${base}/pulls/${number}/reviews`)).flatMap(raw => {
|
|
113
|
-
const review = GhIssueCommentSchema.omit({ created_at: true })
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
const review = GhIssueCommentSchema.omit({ created_at: true })
|
|
114
|
+
.extend({
|
|
115
|
+
submitted_at: z.string().nullable().optional(),
|
|
116
|
+
state: z.string(),
|
|
117
|
+
})
|
|
118
|
+
.parse(raw)
|
|
116
119
|
if (!review.submitted_at || review.state === 'PENDING') return []
|
|
117
120
|
return [{ ...mapIssueComment({ ...review, created_at: review.submitted_at }), state: review.state }]
|
|
118
121
|
})
|
package/src/github/gh.ts
CHANGED
|
@@ -140,14 +140,24 @@ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
|
|
|
140
140
|
}
|
|
141
141
|
const r = await exec(args)
|
|
142
142
|
if (r.code !== 0) {
|
|
143
|
-
throw new GitHubApiError(
|
|
143
|
+
throw new GitHubApiError(
|
|
144
|
+
path,
|
|
145
|
+
r.missingBinary ? 'gh: command not found' : r.stderr,
|
|
146
|
+
r.code,
|
|
147
|
+
r.missingBinary
|
|
148
|
+
)
|
|
144
149
|
}
|
|
145
150
|
return JSON.parse(r.stdout) as unknown
|
|
146
151
|
},
|
|
147
152
|
apiWithHeaders: async path => {
|
|
148
153
|
const r = await exec(['api', '-i', '--method', 'GET', path])
|
|
149
154
|
if (r.code !== 0) {
|
|
150
|
-
throw new GitHubApiError(
|
|
155
|
+
throw new GitHubApiError(
|
|
156
|
+
path,
|
|
157
|
+
r.missingBinary ? 'gh: command not found' : r.stderr,
|
|
158
|
+
r.code,
|
|
159
|
+
r.missingBinary
|
|
160
|
+
)
|
|
151
161
|
}
|
|
152
162
|
return parseIncludedResponse(r.stdout)
|
|
153
163
|
},
|
|
@@ -155,7 +165,12 @@ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
|
|
|
155
165
|
// The payload goes over stdin, so no comment text ever appears in an argument list.
|
|
156
166
|
const r = await exec(['api', '--method', 'POST', path, '--input', '-'], { input: JSON.stringify(body) })
|
|
157
167
|
if (r.code !== 0) {
|
|
158
|
-
throw new GitHubApiError(
|
|
168
|
+
throw new GitHubApiError(
|
|
169
|
+
path,
|
|
170
|
+
r.missingBinary ? 'gh: command not found' : r.stderr,
|
|
171
|
+
r.code,
|
|
172
|
+
r.missingBinary
|
|
173
|
+
)
|
|
159
174
|
}
|
|
160
175
|
return JSON.parse(r.stdout) as unknown
|
|
161
176
|
},
|
|
@@ -81,7 +81,10 @@ export function commentRequest(
|
|
|
81
81
|
return { path: `${base}/pulls/${number}/comments`, body }
|
|
82
82
|
}
|
|
83
83
|
case 'reply':
|
|
84
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`,
|
|
86
|
+
body: { body: input.body },
|
|
87
|
+
}
|
|
85
88
|
case 'issue':
|
|
86
89
|
return { path: `${base}/issues/${number}/comments`, body: { body: input.body } }
|
|
87
90
|
}
|
package/src/github/pr.ts
CHANGED
|
@@ -100,7 +100,10 @@ export function prBaseRef(number: number): string {
|
|
|
100
100
|
* it is local once the base was fetched). Today's base tip would contain the PR and give an
|
|
101
101
|
* empty diff.
|
|
102
102
|
*/
|
|
103
|
-
export async function fetchPrRefs(
|
|
103
|
+
export async function fetchPrRefs(
|
|
104
|
+
git: Git,
|
|
105
|
+
meta: PrMeta
|
|
106
|
+
): Promise<{ headSha: string; mergeBaseSha: string }> {
|
|
104
107
|
await git.fetch('origin', [
|
|
105
108
|
`+pull/${meta.number}/head:${prHeadRef(meta.number)}`,
|
|
106
109
|
`+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
|
package/src/github/threads.ts
CHANGED
|
@@ -33,7 +33,11 @@ export const THREADS_QUERY = `query($owner: String!, $name: String!, $number: In
|
|
|
33
33
|
* The ids of every review comment that sits in a resolved thread. REST has no resolved flag,
|
|
34
34
|
* so this one GraphQL query is joined to the REST comments by id.
|
|
35
35
|
*/
|
|
36
|
-
export async function fetchResolvedCommentIds(
|
|
36
|
+
export async function fetchResolvedCommentIds(
|
|
37
|
+
gh: GitHubClient,
|
|
38
|
+
repo: Repo,
|
|
39
|
+
number: number
|
|
40
|
+
): Promise<Set<number>> {
|
|
37
41
|
const resolved = new Set<number>()
|
|
38
42
|
let after: string | null = null
|
|
39
43
|
for (;;) {
|
package/src/project-config.ts
CHANGED
|
@@ -36,14 +36,16 @@ const capsShape = {
|
|
|
36
36
|
diagram: cap(),
|
|
37
37
|
} satisfies Record<keyof TextCaps, z.ZodOptional<z.ZodNumber>>
|
|
38
38
|
|
|
39
|
-
export const PromptOverridesSchema = z
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
export const PromptOverridesSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
'generation-format.md': z.string().min(1).optional(),
|
|
42
|
+
'generation-strict.md': z.string().min(1).optional(),
|
|
43
|
+
'generation-surfacing.md': z.string().min(1).optional(),
|
|
44
|
+
'quality-standards.md': z.string().min(1).optional(),
|
|
45
|
+
'layering-guidance.md': z.string().min(1).optional(),
|
|
46
|
+
'chat-seed.md': z.string().min(1).optional(),
|
|
47
|
+
})
|
|
48
|
+
.strict()
|
|
47
49
|
export type PromptOverrides = z.infer<typeof PromptOverridesSchema>
|
|
48
50
|
|
|
49
51
|
export const ProjectConfigSchema = z.object({
|
|
@@ -119,7 +121,8 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
|
|
|
119
121
|
const generation: ProjectConfig['generation'] = {
|
|
120
122
|
mode: user.generation?.mode ?? DEFAULT_PROJECT_CONFIG.generation.mode,
|
|
121
123
|
maxRepairRounds: user.generation?.maxRepairRounds ?? DEFAULT_PROJECT_CONFIG.generation.maxRepairRounds,
|
|
122
|
-
inlineDiffMaxLines:
|
|
124
|
+
inlineDiffMaxLines:
|
|
125
|
+
user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
|
|
123
126
|
smallPrHunks: user.generation?.smallPrHunks ?? DEFAULT_PROJECT_CONFIG.generation.smallPrHunks,
|
|
124
127
|
}
|
|
125
128
|
if (user.generation?.caps !== undefined) {
|
package/src/prompt-files.ts
CHANGED
|
@@ -15,9 +15,10 @@ export async function loadPromptFile(
|
|
|
15
15
|
project?: ProjectPrompts
|
|
16
16
|
): Promise<string> {
|
|
17
17
|
const override = project?.overrides?.[name]
|
|
18
|
-
const file =
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const file =
|
|
19
|
+
project !== undefined && override !== undefined
|
|
20
|
+
? path.resolve(project.repoRoot, override)
|
|
21
|
+
: path.join(dir, name)
|
|
21
22
|
try {
|
|
22
23
|
return await readFile(file, 'utf8')
|
|
23
24
|
} catch (cause) {
|
package/src/review/doctor.ts
CHANGED
|
@@ -88,7 +88,11 @@ export async function checkSkill(
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
if (stale.length > 0) {
|
|
91
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
detail: `outdated or modified skill: ${stale.join(', ')}`,
|
|
94
|
+
hint: 'run `pr-review install-skill`',
|
|
95
|
+
}
|
|
92
96
|
}
|
|
93
97
|
if (found.length === 0) {
|
|
94
98
|
return {
|
|
@@ -113,7 +117,10 @@ async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
|
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/** Runs core checks and, with allChecks, checks acpx for AI Chat. */
|
|
116
|
-
export async function runDoctorChecks(
|
|
120
|
+
export async function runDoctorChecks(
|
|
121
|
+
deps: DoctorDeps,
|
|
122
|
+
options: { allChecks?: boolean } = {}
|
|
123
|
+
): Promise<DoctorReport> {
|
|
117
124
|
let repoRoot: string | null = null
|
|
118
125
|
let git: DoctorCheck
|
|
119
126
|
try {
|
package/src/review/normalize.ts
CHANGED
|
@@ -195,7 +195,9 @@ export function artifactToModelOutput(artifact: ReviewArtifact): ModelOutput {
|
|
|
195
195
|
summary: artifact.summary,
|
|
196
196
|
layers: artifact.layers.map(layer => {
|
|
197
197
|
const { id: _id, risk, files, ...rest } = layer
|
|
198
|
-
const modelRisk = risk
|
|
198
|
+
const modelRisk = risk
|
|
199
|
+
.filter(r => r.source === 'model')
|
|
200
|
+
.map(r => ({ label: r.label, reason: r.reason ?? '' }))
|
|
199
201
|
const out: ModelLayer = { ...rest, files: files.map(({ isTest: _isTest, ...f }) => f) }
|
|
200
202
|
if (modelRisk.length > 0) {
|
|
201
203
|
out.risk = modelRisk
|
package/src/review/prepare.ts
CHANGED
|
@@ -37,7 +37,12 @@ async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['l
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** A change set before a PR exists: the refs must already be in the clone. */
|
|
40
|
-
async function resolveRefs(
|
|
40
|
+
async function resolveRefs(
|
|
41
|
+
ctx: AppContext,
|
|
42
|
+
base: string,
|
|
43
|
+
head: string,
|
|
44
|
+
log: PrepareOptions['log']
|
|
45
|
+
): Promise<Pr> {
|
|
41
46
|
log('fetch-refs')
|
|
42
47
|
const headSha = await ctx.git.revParse(head)
|
|
43
48
|
const mergeBaseSha = await ctx.git.mergeBase(base, headSha)
|
|
@@ -93,7 +98,11 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
|
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
export async function prepare(
|
|
101
|
+
export async function prepare(
|
|
102
|
+
ctx: AppContext,
|
|
103
|
+
target: PrepareTarget,
|
|
104
|
+
opts: PrepareOptions
|
|
105
|
+
): Promise<PrepareResult> {
|
|
97
106
|
const pr =
|
|
98
107
|
target.kind === 'pr'
|
|
99
108
|
? await resolvePr(ctx, target.number, opts.log)
|
|
@@ -116,7 +125,8 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
|
|
|
116
125
|
const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
|
|
117
126
|
const additions = derived.files.reduce((n, f) => n + f.additions, 0)
|
|
118
127
|
const deletions = derived.files.reduce((n, f) => n + f.deletions, 0)
|
|
119
|
-
const fullPr: Pr =
|
|
128
|
+
const fullPr: Pr =
|
|
129
|
+
target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
|
|
120
130
|
const derivedDir = ctx.derived.derivedDir(pr.headSha)
|
|
121
131
|
const config = ctx.projectConfig.config
|
|
122
132
|
|
|
@@ -152,14 +162,20 @@ export async function prepare(ctx: AppContext, target: PrepareTarget, opts: Prep
|
|
|
152
162
|
largePr: isLargePr({ files: derived.files.length, additions, deletions }),
|
|
153
163
|
preparedAt: ctx.now().toISOString(),
|
|
154
164
|
}
|
|
155
|
-
const sources =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
165
|
+
const sources =
|
|
166
|
+
opts.promptSources ??
|
|
167
|
+
(await loadPromptSources(undefined, {
|
|
168
|
+
repoRoot: ctx.config.repoRoot,
|
|
169
|
+
overrides: ctx.projectConfig.config.prompts,
|
|
170
|
+
}))
|
|
159
171
|
await clearCanvasDir(canvasDir)
|
|
160
172
|
await writeTextAtomic(promptPath, renderPrompt(context, derived.patches, sources))
|
|
161
173
|
await writeJsonAtomic(contextPath, context)
|
|
162
174
|
// The log keeps the whole history; publish counts attempts from this line on.
|
|
163
|
-
await appendFile(
|
|
175
|
+
await appendFile(
|
|
176
|
+
path.join(canvasDir, 'publish.log'),
|
|
177
|
+
`${context.preparedAt} prepared ${pr.headSha}\n`,
|
|
178
|
+
'utf8'
|
|
179
|
+
)
|
|
164
180
|
return { ...result, status: 'prepared' }
|
|
165
181
|
}
|
package/src/review/prompt.ts
CHANGED
|
@@ -71,7 +71,9 @@ function configuredLayersMarkdown(ctx: GenerationContext): string {
|
|
|
71
71
|
return ctx.defaultLayers
|
|
72
72
|
.map((l, i) => {
|
|
73
73
|
const paths =
|
|
74
|
-
l.paths === undefined || l.paths.length === 0
|
|
74
|
+
l.paths === undefined || l.paths.length === 0
|
|
75
|
+
? ''
|
|
76
|
+
: ` Path hints: ${l.paths.map(p => `\`${p}\``).join(', ')}.`
|
|
75
77
|
return `${i + 1}. \`${l.id}\` **${l.title}** — ${l.description}${paths}`
|
|
76
78
|
})
|
|
77
79
|
.join('\n')
|
|
@@ -196,7 +198,11 @@ export function schemaMarkdown(ctx: GenerationContext): string {
|
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
/** Selects one task and fills its data and format placeholders before the generator sees it. */
|
|
199
|
-
export function renderPrompt(
|
|
201
|
+
export function renderPrompt(
|
|
202
|
+
ctx: GenerationContext,
|
|
203
|
+
patches: Record<string, string>,
|
|
204
|
+
sources: PromptSources
|
|
205
|
+
): string {
|
|
200
206
|
const tokens: Record<string, string> = {
|
|
201
207
|
TARGET_WORD: ctx.target.kind === 'pr' ? 'pull request' : 'change set',
|
|
202
208
|
META: metaMarkdown(ctx),
|
package/src/review/publish.ts
CHANGED
|
@@ -75,7 +75,11 @@ async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error:
|
|
|
75
75
|
const file = path.join(canvasDir, 'model.json')
|
|
76
76
|
const text = await readText(file)
|
|
77
77
|
if (text === null) {
|
|
78
|
-
throw new PublishError(
|
|
78
|
+
throw new PublishError(
|
|
79
|
+
'NOT_FOUND',
|
|
80
|
+
`${file} does not exist`,
|
|
81
|
+
'write the model output there and publish again'
|
|
82
|
+
)
|
|
79
83
|
}
|
|
80
84
|
return parseModelText(text, 'model.json')
|
|
81
85
|
}
|
|
@@ -141,7 +145,11 @@ export async function validationInput(
|
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
export function buildManifest(
|
|
148
|
+
export function buildManifest(
|
|
149
|
+
context: GenerationContext,
|
|
150
|
+
artifact: ReviewArtifact,
|
|
151
|
+
version: string
|
|
152
|
+
): CanvasManifest {
|
|
145
153
|
const manifest: CanvasManifest = {
|
|
146
154
|
formatVersion: 1,
|
|
147
155
|
tool: { name: 'pr-review', version },
|
|
@@ -159,7 +167,11 @@ export function buildManifest(context: GenerationContext, artifact: ReviewArtifa
|
|
|
159
167
|
return manifest
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
export async function publish(
|
|
170
|
+
export async function publish(
|
|
171
|
+
ctx: AppContext,
|
|
172
|
+
canvasDir: string,
|
|
173
|
+
opts: PublishOptions
|
|
174
|
+
): Promise<PublishResult> {
|
|
163
175
|
const context = await readContext(canvasDir)
|
|
164
176
|
if (!opts.allowStale) {
|
|
165
177
|
const head = await currentHead(ctx, context)
|
package/src/review/trim-caps.ts
CHANGED
|
@@ -77,7 +77,10 @@ interface TitledOutput {
|
|
|
77
77
|
* Titles only: a rationale or a body is prose a reader will read, and cutting it is the author's
|
|
78
78
|
* call, not the tool's.
|
|
79
79
|
*/
|
|
80
|
-
export function applyTitleTrims(
|
|
80
|
+
export function applyTitleTrims(
|
|
81
|
+
output: unknown,
|
|
82
|
+
caps: { layerTitle: number; pointTitle: number }
|
|
83
|
+
): TitleTrim[] {
|
|
81
84
|
const trims: TitleTrim[] = []
|
|
82
85
|
const trim = (where: string, holder: { title?: string }, cap: number): void => {
|
|
83
86
|
if (typeof holder.title !== 'string') {
|
|
@@ -89,11 +92,12 @@ export function applyTitleTrims(output: unknown, caps: { layerTitle: number; poi
|
|
|
89
92
|
holder.title = trimmed
|
|
90
93
|
} else if (visibleLength(holder.title) > cap) {
|
|
91
94
|
const match = EXPLAINER.exec(holder.title.trim().replace(/\s+/g, ' '))
|
|
92
|
-
const reason =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
const reason =
|
|
96
|
+
match === null
|
|
97
|
+
? 'no explainer separator (:, —, – or -) to drop'
|
|
98
|
+
: match.index === 0
|
|
99
|
+
? 'dropping the explainer would leave an empty title'
|
|
100
|
+
: 'the title before the explainer still exceeds the cap'
|
|
97
101
|
trims.push({ outcome: 'unfixable', where, length: visibleLength(holder.title), cap, reason })
|
|
98
102
|
}
|
|
99
103
|
}
|