@vintasoftware/pr-review-canvas 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// `pr-review upgrade`: brings pr-review, acpx, and the project's copy of the skill up to date. It
|
|
2
|
+
// says what it will change and asks first. Once pr-review itself moves, the new version checks the
|
|
3
|
+
// skill, so the skill it ships is stamped by the code that ships it.
|
|
4
|
+
import { realpath } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { parseArgs } from 'node:util'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { type CliIo, EXIT, printJson, UsageError } from './commands.js'
|
|
9
|
+
import { findSkillCopies, type ReadSkill, type SkillCopy } from './review/doctor.js'
|
|
10
|
+
import { installSkill, SkillDirExistsError } from './review/install-skill.js'
|
|
11
|
+
|
|
12
|
+
export const ACPX_PACKAGE = 'acpx'
|
|
13
|
+
|
|
14
|
+
export interface CommandResult {
|
|
15
|
+
ok: boolean
|
|
16
|
+
stdout: string
|
|
17
|
+
stderr: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UpgradeDeps {
|
|
21
|
+
/** The running pr-review: its package name, version, and where it is installed. */
|
|
22
|
+
packageName: string
|
|
23
|
+
version: string
|
|
24
|
+
packageRoot: string
|
|
25
|
+
/** The repository whose skill copies are refreshed, or null outside one. */
|
|
26
|
+
repoRoot: string | null
|
|
27
|
+
acpxVersion: () => Promise<string | null>
|
|
28
|
+
/** The acpx that PATH runs, which chat uses. */
|
|
29
|
+
acpxPath: string | null
|
|
30
|
+
/** Runs one command without a shell. Only `npm` is run. */
|
|
31
|
+
run: (file: string, args: string[]) => Promise<CommandResult>
|
|
32
|
+
/** Runs `pr-review <args>` from the package on disk, which is the new one after its upgrade. */
|
|
33
|
+
runInstalled: (args: string[]) => Promise<CommandResult>
|
|
34
|
+
/** Asks a yes/no question, or returns null when there is no one to ask. */
|
|
35
|
+
confirm: (question: string) => Promise<boolean | null>
|
|
36
|
+
readSkill?: ReadSkill
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What one step upgrades, in the order a plan runs them. */
|
|
40
|
+
const StepKindSchema = z.enum(['package', 'acpx', 'skill'])
|
|
41
|
+
type StepKind = z.infer<typeof StepKindSchema>
|
|
42
|
+
|
|
43
|
+
const NpmStepSchema = z.object({
|
|
44
|
+
kind: z.enum(['package', 'acpx']),
|
|
45
|
+
name: z.string(),
|
|
46
|
+
from: z.string(),
|
|
47
|
+
to: z.string(),
|
|
48
|
+
})
|
|
49
|
+
const SkillStepSchema = z.object({ kind: z.literal('skill'), paths: z.array(z.string()) })
|
|
50
|
+
const OutcomeFields = {
|
|
51
|
+
status: z.enum(['done', 'failed']),
|
|
52
|
+
detail: z.string().optional(),
|
|
53
|
+
}
|
|
54
|
+
const StepOutcomeSchema = z.discriminatedUnion('kind', [
|
|
55
|
+
NpmStepSchema.extend(OutcomeFields),
|
|
56
|
+
SkillStepSchema.extend(OutcomeFields),
|
|
57
|
+
])
|
|
58
|
+
/** What an applied upgrade prints, and so what the parent reads back from the new version. */
|
|
59
|
+
const AppliedReportSchema = z.object({ steps: z.array(StepOutcomeSchema) })
|
|
60
|
+
|
|
61
|
+
export type UpgradeStep = z.infer<typeof NpmStepSchema> | z.infer<typeof SkillStepSchema>
|
|
62
|
+
export type StepOutcome = z.infer<typeof StepOutcomeSchema>
|
|
63
|
+
|
|
64
|
+
export interface UpgradePlan {
|
|
65
|
+
steps: UpgradeStep[]
|
|
66
|
+
/** What was looked at and left alone, and why. */
|
|
67
|
+
notes: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `1.2.10` against `1.2.9`, numerically. A prerelease tag is ignored. */
|
|
71
|
+
export function isNewer(candidate: string, current: string): boolean {
|
|
72
|
+
const parts = (v: string): number[] => v.trim().replace(/^v/, '').split(/[-+]/)[0]!.split('.').map(Number)
|
|
73
|
+
const a = parts(candidate)
|
|
74
|
+
const b = parts(current)
|
|
75
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
76
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0)
|
|
77
|
+
if (Number.isNaN(diff)) return false
|
|
78
|
+
if (diff !== 0) return diff > 0
|
|
79
|
+
}
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function latestVersion(deps: UpgradeDeps, name: string): Promise<string | null> {
|
|
84
|
+
const result = await deps.run('npm', ['view', name, 'version'])
|
|
85
|
+
const version = result.stdout.trim()
|
|
86
|
+
return result.ok && version !== '' ? version : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `installed` lies in the copy of `name` that `npm install -g` replaces. Installing over
|
|
91
|
+
* any other copy (pnpm, brew, another node) would leave the one in use as it was.
|
|
92
|
+
*/
|
|
93
|
+
async function ownedByGlobalNpm(
|
|
94
|
+
globalRoot: string | null,
|
|
95
|
+
name: string,
|
|
96
|
+
installed: string
|
|
97
|
+
): Promise<boolean> {
|
|
98
|
+
if (globalRoot === null) return false
|
|
99
|
+
try {
|
|
100
|
+
const globalCopy = await realpath(path.join(globalRoot, name))
|
|
101
|
+
const real = await realpath(installed)
|
|
102
|
+
return real === globalCopy || real.startsWith(`${globalCopy}${path.sep}`)
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function notGlobalNote(name: string, latest: string, installed: string): string {
|
|
109
|
+
return `${name} ${latest} is out, but the copy in use (${installed}) is not the global npm install; update it the way you installed it`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function skillCopies(deps: UpgradeDeps): Promise<SkillCopy[]> {
|
|
113
|
+
return deps.repoRoot === null ? [] : findSkillCopies(deps.repoRoot, deps.readSkill)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function planUpgrade(deps: UpgradeDeps): Promise<UpgradePlan> {
|
|
117
|
+
const steps: UpgradeStep[] = []
|
|
118
|
+
const notes: string[] = []
|
|
119
|
+
|
|
120
|
+
const root = await deps.run('npm', ['root', '-g'])
|
|
121
|
+
const globalRoot = root.ok ? root.stdout.trim() : null
|
|
122
|
+
const latest = await latestVersion(deps, deps.packageName)
|
|
123
|
+
if (latest === null) {
|
|
124
|
+
notes.push(`${deps.packageName}: could not read the latest version from npm`)
|
|
125
|
+
} else if (!isNewer(latest, deps.version)) {
|
|
126
|
+
notes.push(`${deps.packageName} ${deps.version} is up to date`)
|
|
127
|
+
} else if (await ownedByGlobalNpm(globalRoot, deps.packageName, deps.packageRoot)) {
|
|
128
|
+
steps.push({ kind: 'package', name: deps.packageName, from: deps.version, to: latest })
|
|
129
|
+
} else {
|
|
130
|
+
notes.push(notGlobalNote(deps.packageName, latest, deps.packageRoot))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const acpx = deps.acpxPath === null ? null : await deps.acpxVersion()
|
|
134
|
+
const acpxLatest = acpx === null ? null : await latestVersion(deps, ACPX_PACKAGE)
|
|
135
|
+
if (acpx === null || deps.acpxPath === null) {
|
|
136
|
+
notes.push('acpx is not installed; AI Chat needs it: npm install -g acpx@latest')
|
|
137
|
+
} else if (acpxLatest === null) {
|
|
138
|
+
notes.push('acpx: could not read the latest version from npm')
|
|
139
|
+
} else if (!isNewer(acpxLatest, acpx)) {
|
|
140
|
+
notes.push(`acpx ${acpx} is up to date`)
|
|
141
|
+
} else if (await ownedByGlobalNpm(globalRoot, ACPX_PACKAGE, deps.acpxPath)) {
|
|
142
|
+
steps.push({ kind: 'acpx', name: ACPX_PACKAGE, from: acpx, to: acpxLatest })
|
|
143
|
+
} else {
|
|
144
|
+
notes.push(notGlobalNote(ACPX_PACKAGE, acpxLatest, deps.acpxPath))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (deps.repoRoot === null) {
|
|
148
|
+
notes.push('not in a repository, so no project skill to refresh')
|
|
149
|
+
} else {
|
|
150
|
+
const copies = await skillCopies(deps)
|
|
151
|
+
// A new pr-review can ship a new skill, so every copy is in question once the package moves.
|
|
152
|
+
const packageMoves = steps.some(step => step.kind === 'package')
|
|
153
|
+
const toCheck = copies.filter(copy => packageMoves || copy.stale)
|
|
154
|
+
if (copies.length === 0) {
|
|
155
|
+
notes.push('the project has no copy of the skill; run `pr-review install-skill` to add one')
|
|
156
|
+
} else if (toCheck.length > 0) {
|
|
157
|
+
steps.push({ kind: 'skill', paths: toCheck.map(copy => copy.path) })
|
|
158
|
+
} else {
|
|
159
|
+
notes.push(`the project skill matches pr-review ${deps.version}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { steps, notes }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function describeStep(step: UpgradeStep): string {
|
|
166
|
+
if (step.kind === 'skill') {
|
|
167
|
+
return `refresh the project skill where it differs from pr-review's: ${step.paths.join(', ')}`
|
|
168
|
+
}
|
|
169
|
+
return `upgrade ${step.name} ${step.from} -> ${step.to} (npm install -g ${step.name}@${step.to})`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function applySkill(deps: UpgradeDeps): Promise<{ written: string[]; skipped: string[] }> {
|
|
173
|
+
const written: string[] = []
|
|
174
|
+
const skipped: string[] = []
|
|
175
|
+
for (const copy of await skillCopies(deps)) {
|
|
176
|
+
if (!copy.stale) continue
|
|
177
|
+
try {
|
|
178
|
+
await installSkill({ targets: [{ kind: copy.kind, dir: copy.dir }] })
|
|
179
|
+
written.push(copy.path)
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (!(err instanceof SkillDirExistsError)) throw err
|
|
182
|
+
skipped.push(copy.path)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { written, skipped }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function applySkillStep(
|
|
189
|
+
step: z.infer<typeof SkillStepSchema>,
|
|
190
|
+
deps: UpgradeDeps
|
|
191
|
+
): Promise<StepOutcome> {
|
|
192
|
+
const { written, skipped } = await applySkill(deps)
|
|
193
|
+
if (skipped.length > 0) {
|
|
194
|
+
return {
|
|
195
|
+
...step,
|
|
196
|
+
paths: written,
|
|
197
|
+
status: 'failed',
|
|
198
|
+
detail: `not a managed copy, left alone: ${skipped.join(', ')}; run \`pr-review install-skill --force\` to replace it`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ...step, paths: written, status: 'done' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The new pr-review's own `upgrade --yes --only <kinds>`: it plans again with its own code, and runs
|
|
206
|
+
* only the kinds of step the user confirmed. Its report's steps join this one's, so this process
|
|
207
|
+
* stays the one that prints them.
|
|
208
|
+
*/
|
|
209
|
+
async function handOff(deps: UpgradeDeps, kinds: StepKind[]): Promise<StepOutcome[]> {
|
|
210
|
+
const result = await deps.runInstalled([
|
|
211
|
+
'upgrade',
|
|
212
|
+
'--yes',
|
|
213
|
+
'--only',
|
|
214
|
+
kinds.join(','),
|
|
215
|
+
...(deps.repoRoot === null ? [] : ['--repo', deps.repoRoot]),
|
|
216
|
+
])
|
|
217
|
+
let report: unknown
|
|
218
|
+
try {
|
|
219
|
+
// The report is the last line; String() turns a missing one into text JSON.parse rejects.
|
|
220
|
+
report = JSON.parse(String(result.stdout.trim().split('\n').at(-1)))
|
|
221
|
+
} catch {
|
|
222
|
+
report = null
|
|
223
|
+
}
|
|
224
|
+
const parsed = AppliedReportSchema.safeParse(report)
|
|
225
|
+
if (parsed.success) return parsed.data.steps
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
kind: 'skill',
|
|
229
|
+
paths: [],
|
|
230
|
+
status: 'failed',
|
|
231
|
+
detail:
|
|
232
|
+
`the new pr-review did not report a result; run \`pr-review upgrade\` again\n` +
|
|
233
|
+
result.stderr.trim().split('\n').slice(-3).join('\n'),
|
|
234
|
+
},
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Runs every step in order. A failed npm install does not stop the rest. Once pr-review itself is
|
|
240
|
+
* upgraded, the new version plans and runs whatever is left, so the skill it ships is checked and
|
|
241
|
+
* copied by its own code.
|
|
242
|
+
*/
|
|
243
|
+
export async function applyUpgrade(plan: UpgradePlan, deps: UpgradeDeps): Promise<StepOutcome[]> {
|
|
244
|
+
const outcomes: StepOutcome[] = []
|
|
245
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
246
|
+
if (step.kind === 'skill') {
|
|
247
|
+
outcomes.push(await applySkillStep(step, deps))
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
const result = await deps.run('npm', ['install', '-g', `${step.name}@${step.to}`])
|
|
251
|
+
if (!result.ok) {
|
|
252
|
+
const detail = result.stderr.trim().split('\n').slice(-3).join('\n') || 'npm install failed'
|
|
253
|
+
outcomes.push({ ...step, status: 'failed', detail })
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
outcomes.push({ ...step, status: 'done' })
|
|
257
|
+
const rest = plan.steps.slice(index + 1)
|
|
258
|
+
if (step.kind === 'package' && rest.length > 0) {
|
|
259
|
+
return [
|
|
260
|
+
...outcomes,
|
|
261
|
+
...(await handOff(
|
|
262
|
+
deps,
|
|
263
|
+
rest.map(later => later.kind)
|
|
264
|
+
)),
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return outcomes
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `--only package,acpx,skill`: the kinds of step this run may take. */
|
|
272
|
+
function parseOnly(raw: string | undefined): ReadonlySet<StepKind> | null {
|
|
273
|
+
if (raw === undefined) return null
|
|
274
|
+
const kinds = z.array(StepKindSchema).safeParse(raw.split(','))
|
|
275
|
+
if (!kinds.success) {
|
|
276
|
+
throw new UsageError(`--only takes a comma list of ${StepKindSchema.options.join(', ')}; got "${raw}"`)
|
|
277
|
+
}
|
|
278
|
+
return new Set(kinds.data)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `upgrade [--yes] [--only <kinds>]`: the plan on stderr, a confirmation, then one JSON line with
|
|
283
|
+
* what happened.
|
|
284
|
+
*/
|
|
285
|
+
export async function runUpgrade(deps: UpgradeDeps, argv: string[], io: CliIo): Promise<number> {
|
|
286
|
+
const { values } = parseArgs({
|
|
287
|
+
args: argv,
|
|
288
|
+
options: { yes: { type: 'boolean', short: 'y' }, only: { type: 'string' } },
|
|
289
|
+
strict: true,
|
|
290
|
+
})
|
|
291
|
+
const only = parseOnly(values.only)
|
|
292
|
+
const full = await planUpgrade(deps)
|
|
293
|
+
const plan: UpgradePlan =
|
|
294
|
+
only === null
|
|
295
|
+
? full
|
|
296
|
+
: {
|
|
297
|
+
steps: full.steps.filter(step => only.has(step.kind)),
|
|
298
|
+
notes: [
|
|
299
|
+
...full.notes,
|
|
300
|
+
...full.steps
|
|
301
|
+
.filter(step => !only.has(step.kind))
|
|
302
|
+
.map(step => `left out by --only: ${describeStep(step)}`),
|
|
303
|
+
],
|
|
304
|
+
}
|
|
305
|
+
for (const note of plan.notes) io.stderr(` ${note}`)
|
|
306
|
+
if (plan.steps.length === 0) {
|
|
307
|
+
io.stderr('Everything is up to date.')
|
|
308
|
+
printJson(io, { applied: false, steps: [], notes: plan.notes })
|
|
309
|
+
return EXIT.ok
|
|
310
|
+
}
|
|
311
|
+
io.stderr('pr-review upgrade will:')
|
|
312
|
+
for (const step of plan.steps) io.stderr(` - ${describeStep(step)}`)
|
|
313
|
+
if (plan.steps.some(step => step.kind === 'package')) {
|
|
314
|
+
io.stderr(' Once pr-review is upgraded, the new version checks and runs the steps after it.')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const confirmed = values.yes === true ? true : await deps.confirm('Proceed? [y/N] ')
|
|
318
|
+
if (confirmed !== true) {
|
|
319
|
+
io.stderr(confirmed === null ? 'Nothing changed. Re-run with --yes to apply.' : 'Nothing changed.')
|
|
320
|
+
printJson(io, { applied: false, steps: plan.steps, notes: plan.notes })
|
|
321
|
+
return EXIT.ok
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const outcomes = await applyUpgrade(plan, deps)
|
|
325
|
+
for (const outcome of outcomes) {
|
|
326
|
+
io.stderr(
|
|
327
|
+
` ${outcome.status}: ${describeStep(outcome)}${outcome.detail ? `\n ${outcome.detail}` : ''}`
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
// A skill step lists only the copies it wrote, failed or not.
|
|
331
|
+
const refreshed = outcomes.flatMap(o => (o.kind === 'skill' ? o.paths : []))
|
|
332
|
+
if (refreshed.length > 0) {
|
|
333
|
+
io.stderr(`The project skill changed. Commit and push ${refreshed.join(' and ')} so your team gets it.`)
|
|
334
|
+
}
|
|
335
|
+
const ok = outcomes.every(o => o.status !== 'failed')
|
|
336
|
+
printJson(io, { applied: true, ok, steps: outcomes, notes: plan.notes })
|
|
337
|
+
return ok ? EXIT.ok : EXIT.error
|
|
338
|
+
}
|
package/static/js/api.js
CHANGED
|
@@ -176,6 +176,60 @@ export function postComment(prNumber, input, opts = {}) {
|
|
|
176
176
|
})
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Adds one comment to the pending review. Nothing reaches the forge: it is kept in the local
|
|
181
|
+
* state until the review is submitted.
|
|
182
|
+
* @param {ReviewKey} prNumber
|
|
183
|
+
* @param {import('./contract-types.js').AddPendingInput} input
|
|
184
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
185
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
186
|
+
*/
|
|
187
|
+
export function addPending(prNumber, input, opts = {}) {
|
|
188
|
+
return fetchJson(`/api/prs/${prNumber}/pending`, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
body: input,
|
|
191
|
+
fetchImpl: opts.fetchImpl,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {ReviewKey} prNumber
|
|
197
|
+
* @param {string} id
|
|
198
|
+
* @param {string} body
|
|
199
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
200
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
201
|
+
*/
|
|
202
|
+
export function editPending(prNumber, id, body, opts = {}) {
|
|
203
|
+
return fetchJson(`/api/prs/${prNumber}/pending/${encodeURIComponent(id)}`, {
|
|
204
|
+
method: 'PATCH',
|
|
205
|
+
body: { body },
|
|
206
|
+
fetchImpl: opts.fetchImpl,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {ReviewKey} prNumber
|
|
212
|
+
* @param {string} id
|
|
213
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
214
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
215
|
+
*/
|
|
216
|
+
export function deletePending(prNumber, id, opts = {}) {
|
|
217
|
+
return fetchJson(`/api/prs/${prNumber}/pending/${encodeURIComponent(id)}`, {
|
|
218
|
+
method: 'DELETE',
|
|
219
|
+
fetchImpl: opts.fetchImpl,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Throws the whole pending review away.
|
|
225
|
+
* @param {ReviewKey} prNumber
|
|
226
|
+
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
227
|
+
* @returns {Promise<import('./contract-types.js').StateResponse>}
|
|
228
|
+
*/
|
|
229
|
+
export function discardPending(prNumber, opts = {}) {
|
|
230
|
+
return fetchJson(`/api/prs/${prNumber}/pending`, { method: 'DELETE', fetchImpl: opts.fetchImpl })
|
|
231
|
+
}
|
|
232
|
+
|
|
179
233
|
/**
|
|
180
234
|
* @param {ReviewKey} prNumber
|
|
181
235
|
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
@@ -187,7 +241,7 @@ export function fetchReviewBody(prNumber, opts = {}) {
|
|
|
187
241
|
|
|
188
242
|
/**
|
|
189
243
|
* @param {ReviewKey} prNumber
|
|
190
|
-
* @param {{ event: '
|
|
244
|
+
* @param {{ event: import('./contract-types.js').ReviewEvent, body?: string, headSha?: string, includePending?: boolean }} input
|
|
191
245
|
* @param {{ fetchImpl?: typeof fetch }} [opts]
|
|
192
246
|
* @returns {Promise<import('./contract-types.js').PostReviewResponse>}
|
|
193
247
|
*/
|
package/static/js/app.js
CHANGED
|
@@ -13,30 +13,43 @@ import {
|
|
|
13
13
|
saveAppearance,
|
|
14
14
|
} from './api.js'
|
|
15
15
|
import { setChatEnabled } from './ask.js'
|
|
16
|
-
import { readChatWidth, renderChatShell, wireChat } from './chat.js'
|
|
16
|
+
import { readChatMinimized, readChatWidth, renderChatShell, wireChat } from './chat.js'
|
|
17
17
|
import { runCommand, wireCopyCommands } from './commands.js'
|
|
18
18
|
import { initDeepLinks } from './deep-link.js'
|
|
19
19
|
import { initDiagrams } from './diagram.js'
|
|
20
20
|
import { esc, qs } from './dom.js'
|
|
21
21
|
import { exportCanvasZip } from './download.js'
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
carriedOverBarHtml,
|
|
24
|
+
marksCarriedBarHtml,
|
|
25
|
+
renderEmptyState,
|
|
26
|
+
renderStaleState,
|
|
27
|
+
staleBarHtml,
|
|
28
|
+
} from './empty-state.js'
|
|
23
29
|
import { errorCardHtml } from './errors.js'
|
|
24
30
|
import { renderHeader } from './header.js'
|
|
25
31
|
import { wireDropZone } from './import-zone.js'
|
|
26
32
|
import { toast, wireReview } from './interactions.js'
|
|
27
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
defineLayerElements,
|
|
35
|
+
pathSet,
|
|
36
|
+
renderLayers,
|
|
37
|
+
renderRail,
|
|
38
|
+
setFoldLevel,
|
|
39
|
+
setRenderContext,
|
|
40
|
+
} from './layers.js'
|
|
28
41
|
import { renderOverview } from './overview.js'
|
|
29
42
|
import { wireQuickQuestions } from './quick-questions.js'
|
|
30
43
|
import { canvasChanged, openRegenerateDialog } from './regenerate.js'
|
|
31
44
|
import { createReviewSession } from './review-session.js'
|
|
32
45
|
import { initScrollSpy } from './scroll-spy.js'
|
|
33
46
|
import { openSettingsDialog } from './settings.js'
|
|
34
|
-
import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
|
|
47
|
+
import { applySkin, DEFAULT_SKIN, nextSkin, readSkin, skinLabel } from './skin.js'
|
|
35
48
|
import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
|
|
36
49
|
import { hostLabel, setHost } from './host.js'
|
|
37
50
|
|
|
38
51
|
/** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
|
|
39
|
-
/** @typedef {
|
|
52
|
+
/** @typedef {import('./contract-types.js').ReviewBootstrap} Bootstrap */
|
|
40
53
|
|
|
41
54
|
/** @returns {Bootstrap | null} */
|
|
42
55
|
function readBootstrap() {
|
|
@@ -109,7 +122,7 @@ export class PrAppElement extends HTMLElement {
|
|
|
109
122
|
/** @type {import('./theme.js').Theme} */
|
|
110
123
|
theme = 'auto'
|
|
111
124
|
/** @type {import('./skin.js').Skin} */
|
|
112
|
-
skin =
|
|
125
|
+
skin = DEFAULT_SKIN
|
|
113
126
|
/** @type {{ stop: () => void } | null} */
|
|
114
127
|
diagrams = null
|
|
115
128
|
/** @type {{ stop: () => void } | null} */
|
|
@@ -160,6 +173,9 @@ export class PrAppElement extends HTMLElement {
|
|
|
160
173
|
setHost(this.bootstrap.host)
|
|
161
174
|
this.theme = readTheme(document.documentElement)
|
|
162
175
|
this.skin = readSkin(document.documentElement)
|
|
176
|
+
// The reading level the canvas opens at comes from the settings file with the page, so the
|
|
177
|
+
// first draw hides what the reader asked for.
|
|
178
|
+
setFoldLevel(this, this.bootstrap.foldLevel)
|
|
163
179
|
const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
|
|
164
180
|
r => r.patches,
|
|
165
181
|
() => null
|
|
@@ -199,6 +215,7 @@ export class PrAppElement extends HTMLElement {
|
|
|
199
215
|
const header = renderHeader(bundle, { host: location.host, theme: this.theme, skin: this.skin, now })
|
|
200
216
|
const chatEnabled = bundle.chat.enabled
|
|
201
217
|
const storage = typeof localStorage === 'undefined' ? null : localStorage
|
|
218
|
+
const chatMinimized = chatEnabled && readChatMinimized(storage)
|
|
202
219
|
// The renderers read this while they build the cards, so it is set before the first one.
|
|
203
220
|
setChatEnabled(chatEnabled)
|
|
204
221
|
const showsCanvas = bundle.artifact !== undefined && (bundle.status === 'ready' || this.viewStale)
|
|
@@ -218,10 +235,12 @@ export class PrAppElement extends HTMLElement {
|
|
|
218
235
|
staleSha
|
|
219
236
|
)
|
|
220
237
|
// The context is set before any <pr-file> connects, so each card renders once, when visible.
|
|
238
|
+
const headSha = staleSha ?? bundle.pr.headSha
|
|
221
239
|
setRenderContext({
|
|
222
240
|
artifact,
|
|
223
241
|
files,
|
|
224
242
|
patches,
|
|
243
|
+
headSha,
|
|
225
244
|
comments: bundle.comments.reviewComments,
|
|
226
245
|
state: bundle.state,
|
|
227
246
|
now,
|
|
@@ -233,10 +252,12 @@ export class PrAppElement extends HTMLElement {
|
|
|
233
252
|
: bundle.carriedOver
|
|
234
253
|
? carriedOverBarHtml(bundle.carriedOver)
|
|
235
254
|
: ''
|
|
255
|
+
const marksBar =
|
|
256
|
+
bundle.marksCarriedFrom === undefined ? '' : marksCarriedBarHtml(bundle.marksCarriedFrom)
|
|
236
257
|
this.innerHTML =
|
|
237
258
|
header +
|
|
238
259
|
bannerHtml(bundle.warnings) +
|
|
239
|
-
`<div class="layout${chatEnabled ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage) })}</div>` +
|
|
260
|
+
`<div class="layout${chatEnabled && !chatMinimized ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${marksBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments, headSha)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage), minimized: chatMinimized })}</div>` +
|
|
240
261
|
footerHtml(boot.version, bundle)
|
|
241
262
|
// The screen is interactive from here: reviewed state, dismissals, threads, and posting.
|
|
242
263
|
// A stale canvas shows the diff of an older commit, so nothing is posted from it: a line
|
package/static/js/chat-panel.js
CHANGED
|
@@ -1,42 +1,65 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
/** Keeps the same chat mounted while switching between the sidebar and floating panel.
|
|
4
|
+
*
|
|
5
|
+
* The chat minimizes at every width. Narrow screens float it over the canvas, so minimizing
|
|
6
|
+
* closes the dialog; wide screens dock it in the layout grid, so minimizing drops the column.
|
|
7
|
+
* Either way the launcher brings back the same pane with its draft and transcript intact.
|
|
8
|
+
*
|
|
4
9
|
* @param {HTMLElement} root
|
|
5
10
|
* @param {HTMLElement} pane
|
|
11
|
+
* @param {{ minimized?: boolean, onMinimizedChange?: (minimized: boolean) => void }} [opts]
|
|
6
12
|
*/
|
|
7
|
-
export function wireChatPanel(root, pane) {
|
|
13
|
+
export function wireChatPanel(root, pane, opts = {}) {
|
|
8
14
|
const dialog = /** @type {HTMLDialogElement} */ (root.querySelector('#chat-dialog'))
|
|
9
15
|
const launcher = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-launcher'))
|
|
10
16
|
const minimize = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-minimize'))
|
|
17
|
+
const layout = root.querySelector('.layout')
|
|
11
18
|
const narrow = window.matchMedia('(max-width: 1360px)')
|
|
12
19
|
const mobile = window.matchMedia('(max-width: 600px)')
|
|
13
|
-
|
|
20
|
+
/** Floating panels start minimized; a docked one starts the way the reader last left it. */
|
|
21
|
+
let floating = false
|
|
22
|
+
let docked = !opts.minimized
|
|
23
|
+
|
|
24
|
+
const shown = () => (narrow.matches ? floating : docked)
|
|
14
25
|
|
|
15
26
|
function sync() {
|
|
16
27
|
if (dialog.open) dialog.close()
|
|
17
28
|
if (narrow.matches) {
|
|
18
29
|
dialog.append(pane)
|
|
19
|
-
|
|
30
|
+
pane.hidden = false
|
|
31
|
+
if (floating) {
|
|
20
32
|
if (mobile.matches) dialog.showModal()
|
|
21
33
|
else dialog.show()
|
|
22
34
|
}
|
|
23
35
|
} else {
|
|
24
36
|
dialog.before(pane)
|
|
37
|
+
pane.hidden = !docked
|
|
25
38
|
}
|
|
26
|
-
|
|
27
|
-
launcher.
|
|
39
|
+
layout?.classList.toggle('no-chat', !narrow.matches && !docked)
|
|
40
|
+
launcher.setAttribute('aria-expanded', String(shown()))
|
|
41
|
+
launcher.hidden = shown()
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
function open() {
|
|
31
|
-
if (
|
|
32
|
-
|
|
45
|
+
if (!shown()) {
|
|
46
|
+
if (narrow.matches) floating = true
|
|
47
|
+
else {
|
|
48
|
+
docked = true
|
|
49
|
+
opts.onMinimizedChange?.(false)
|
|
50
|
+
}
|
|
33
51
|
sync()
|
|
34
52
|
}
|
|
35
|
-
pane
|
|
53
|
+
// The composer sits low in a tall sticky pane, so plain focus() would scroll the canvas to it.
|
|
54
|
+
pane.querySelector('textarea')?.focus({ preventScroll: true })
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
function close() {
|
|
39
|
-
|
|
58
|
+
if (narrow.matches) floating = false
|
|
59
|
+
else {
|
|
60
|
+
docked = false
|
|
61
|
+
opts.onMinimizedChange?.(true)
|
|
62
|
+
}
|
|
40
63
|
sync()
|
|
41
64
|
launcher.focus()
|
|
42
65
|
}
|
package/static/js/chat.js
CHANGED
|
@@ -27,12 +27,13 @@ import { postToLabel } from './host.js'
|
|
|
27
27
|
import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
|
|
28
28
|
|
|
29
29
|
export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
|
|
30
|
+
export const CHAT_MINIMIZED_KEY = 'pr-review.chat-minimized'
|
|
30
31
|
export const CHAT_WIDTH_MIN = 280
|
|
31
32
|
export const CHAT_WIDTH_MAX = 560
|
|
32
33
|
export const CHAT_WIDTH_DEFAULT = 340
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
* @param {{ enabled: boolean, width?: number }} opts
|
|
36
|
+
* @param {{ enabled: boolean, width?: number, minimized?: boolean }} opts
|
|
36
37
|
* @returns {string} '' when AI Chat is disabled
|
|
37
38
|
*/
|
|
38
39
|
export function renderChatShell(opts) {
|
|
@@ -40,8 +41,10 @@ export function renderChatShell(opts) {
|
|
|
40
41
|
return ''
|
|
41
42
|
}
|
|
42
43
|
const width = clampWidth(opts.width ?? CHAT_WIDTH_DEFAULT)
|
|
44
|
+
// The launcher starts hidden and `wireChatPanel` reveals it where it belongs, so a docked
|
|
45
|
+
// chat never paints a launcher over itself on the first frame.
|
|
43
46
|
return (
|
|
44
|
-
|
|
47
|
+
`<aside class="chat" aria-labelledby="chat-h"${opts.minimized ? ' hidden' : ''}>` +
|
|
45
48
|
`<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
|
|
46
49
|
'<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
|
|
47
50
|
'<select id="thread"></select>' +
|
|
@@ -60,7 +63,7 @@ export function renderChatShell(opts) {
|
|
|
60
63
|
'<span class="muted small">enter to send</span></div></form>' +
|
|
61
64
|
'</aside>' +
|
|
62
65
|
'<dialog class="chat-dialog" id="chat-dialog" aria-labelledby="chat-h"></dialog>' +
|
|
63
|
-
'<button class="chat-launcher" id="chat-launcher" type="button" aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
|
|
66
|
+
'<button class="chat-launcher" id="chat-launcher" type="button" hidden aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
|
|
64
67
|
)
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -102,6 +105,23 @@ export function writeChatWidth(storage, width) {
|
|
|
102
105
|
storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Only a wide screen stores this: a floating chat always starts minimized.
|
|
110
|
+
* @param {Storage | null} storage
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function readChatMinimized(storage) {
|
|
114
|
+
return (storage === null ? null : storage.getItem(CHAT_MINIMIZED_KEY)) === '1'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {Storage | null} storage
|
|
119
|
+
* @param {boolean} minimized
|
|
120
|
+
*/
|
|
121
|
+
export function writeChatMinimized(storage, minimized) {
|
|
122
|
+
storage?.setItem(CHAT_MINIMIZED_KEY, minimized ? '1' : '0')
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
/** The questions the quick menu offers, and the one that just focuses the box. */
|
|
106
126
|
export const QUICK_QUESTIONS = [
|
|
107
127
|
'Suggestion to solve this?',
|
|
@@ -749,7 +769,10 @@ export function wireChat(options) {
|
|
|
749
769
|
}
|
|
750
770
|
|
|
751
771
|
const stopResize = wireResize(pane, root, storage, applyWidth)
|
|
752
|
-
const panel = wireChatPanel(root, pane
|
|
772
|
+
const panel = wireChatPanel(root, pane, {
|
|
773
|
+
minimized: readChatMinimized(storage),
|
|
774
|
+
onMinimizedChange: minimized => writeChatMinimized(storage, minimized),
|
|
775
|
+
})
|
|
753
776
|
|
|
754
777
|
form.addEventListener('submit', onSubmit)
|
|
755
778
|
root.addEventListener('click', onClick)
|