@iceinvein/agent-skills 0.1.24 → 0.1.26
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 +1 -0
- package/package.json +1 -1
- package/skills/magpie/README.md +16 -1
- package/skills/magpie/bin/magpie.ts +60 -2
- package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
- package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
- package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
- package/skills/magpie/fixtures/example-pr/pr.json +21 -0
- package/skills/magpie/fixtures/fake-gh.sh +10 -1
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
- package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
- package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
- package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
- package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -182
- package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
- package/skills/magpie/scripts/diff-utils.ts +129 -0
- package/skills/magpie/scripts/file-tree.ts +74 -0
- package/skills/magpie/scripts/gh.ts +1 -0
- package/skills/magpie/scripts/helper.js +402 -192
- package/skills/magpie/scripts/post-cmd.ts +202 -0
- package/skills/magpie/scripts/preview-cmd.ts +300 -0
- package/skills/magpie/scripts/refresh.ts +16 -1
- package/skills/magpie/scripts/render-action-bar.ts +51 -0
- package/skills/magpie/scripts/render-annotation.ts +96 -0
- package/skills/magpie/scripts/render-cmd.ts +24 -1
- package/skills/magpie/scripts/render-diff.ts +125 -0
- package/skills/magpie/scripts/render-file-tree.ts +86 -0
- package/skills/magpie/scripts/render-findings.ts +165 -196
- package/skills/magpie/scripts/render-issues-list.ts +78 -0
- package/skills/magpie/scripts/render-progress.ts +74 -32
- package/skills/magpie/scripts/server.ts +43 -0
- package/skills/magpie/scripts/types.ts +13 -0
- package/skills/magpie/skill.json +4 -2
- package/skills/magpie/templates/styles.css +1288 -629
|
@@ -280,6 +280,208 @@ export function formatReviewSummaryBody(
|
|
|
280
280
|
return lines.join('\n')
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// postFindingsAsReview
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
export type PostReviewInput = {
|
|
288
|
+
runDir: string
|
|
289
|
+
findingIds: string[]
|
|
290
|
+
prNumber: number
|
|
291
|
+
headSha: string
|
|
292
|
+
reviewBody?: string
|
|
293
|
+
ghBin?: string
|
|
294
|
+
dryRun?: boolean
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export type PostReviewCommentResult = {
|
|
298
|
+
id: string
|
|
299
|
+
status: 'posted' | 'failed'
|
|
300
|
+
message?: string
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export type PostReviewResult = {
|
|
304
|
+
reviewId: string | null
|
|
305
|
+
comments: PostReviewCommentResult[]
|
|
306
|
+
command?: string[]
|
|
307
|
+
payload?: string
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Looser finding shape used only inside postFindingsAsReview to accommodate
|
|
311
|
+
// findings.json entries where `file` may be null (not yet anchored to a path).
|
|
312
|
+
type LooseFinding = {
|
|
313
|
+
id: string
|
|
314
|
+
file: string | null
|
|
315
|
+
line: number | null
|
|
316
|
+
title: string
|
|
317
|
+
description: string
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseLooseFinding(raw: unknown): LooseFinding | null {
|
|
321
|
+
if (!raw || typeof raw !== 'object') return null
|
|
322
|
+
const r = raw as Record<string, unknown>
|
|
323
|
+
if (typeof r.id !== 'string') return null
|
|
324
|
+
if (typeof r.title !== 'string') return null
|
|
325
|
+
if (typeof r.description !== 'string') return null
|
|
326
|
+
const file = typeof r.file === 'string' ? r.file : null
|
|
327
|
+
const line = typeof r.line === 'number' ? r.line : null
|
|
328
|
+
return { id: r.id, file, line, title: r.title, description: r.description }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function postFindingsAsReview(input: PostReviewInput): Promise<PostReviewResult> {
|
|
332
|
+
const bin = input.ghBin ?? process.env.MAGPIE_GH_BIN ?? 'gh'
|
|
333
|
+
|
|
334
|
+
// Load findings.json and index by id. Use a loose parser that tolerates
|
|
335
|
+
// null file/line fields which parseFinding (strict) would reject.
|
|
336
|
+
const byId = new Map<string, LooseFinding>()
|
|
337
|
+
try {
|
|
338
|
+
const raw = JSON.parse(await readFile(join(input.runDir, 'findings.json'), 'utf8'))
|
|
339
|
+
if (Array.isArray(raw)) {
|
|
340
|
+
for (const entry of raw) {
|
|
341
|
+
const f = parseLooseFinding(entry)
|
|
342
|
+
if (f) byId.set(f.id, f)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
// findings.json missing or unparseable; byId stays empty
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Also load findings.final.json so we can use strict ReviewFinding formatters.
|
|
350
|
+
const strictById = new Map<string, ReviewFinding>()
|
|
351
|
+
try {
|
|
352
|
+
const raw = JSON.parse(await readFile(join(input.runDir, 'findings.final.json'), 'utf8'))
|
|
353
|
+
if (Array.isArray(raw)) {
|
|
354
|
+
for (const entry of raw) {
|
|
355
|
+
try {
|
|
356
|
+
const f = parseFinding(entry)
|
|
357
|
+
strictById.set(f.id, f)
|
|
358
|
+
} catch {
|
|
359
|
+
// entry doesn't pass strict validation; will fall back to LooseFinding path
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// findings.final.json missing or unparseable; strictById stays empty
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Partition into inline (has file + line) and unplaced.
|
|
368
|
+
type InlineComment = { path: string; line: number; side: 'RIGHT'; body: string }
|
|
369
|
+
const inlineComments: InlineComment[] = []
|
|
370
|
+
const unplacedBodies: string[] = []
|
|
371
|
+
|
|
372
|
+
for (const id of input.findingIds) {
|
|
373
|
+
const f = byId.get(id)
|
|
374
|
+
if (!f) continue
|
|
375
|
+
const strict = strictById.get(id)
|
|
376
|
+
if (f.line != null && f.file != null) {
|
|
377
|
+
const body = strict
|
|
378
|
+
? formatInlineBody(strict)
|
|
379
|
+
: formatFindingDescriptionMarkdown(f.description)
|
|
380
|
+
inlineComments.push({
|
|
381
|
+
path: f.file,
|
|
382
|
+
line: f.line,
|
|
383
|
+
side: 'RIGHT',
|
|
384
|
+
body,
|
|
385
|
+
})
|
|
386
|
+
} else {
|
|
387
|
+
const body = strict
|
|
388
|
+
? formatConversationBody(strict)
|
|
389
|
+
: `**${f.title}** (${f.file ?? 'general'})\n\n${formatFindingDescriptionMarkdown(f.description)}`
|
|
390
|
+
unplacedBodies.push(body)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Build review body.
|
|
395
|
+
const unplacedSection = unplacedBodies.join('\n\n---\n\n')
|
|
396
|
+
const reviewBodyParts = [input.reviewBody, unplacedSection].filter(
|
|
397
|
+
(s): s is string => typeof s === 'string' && s.length > 0,
|
|
398
|
+
)
|
|
399
|
+
const reviewBody = reviewBodyParts.join('\n\n')
|
|
400
|
+
|
|
401
|
+
const payloadObj = {
|
|
402
|
+
commit_id: input.headSha,
|
|
403
|
+
event: 'COMMENT',
|
|
404
|
+
body: reviewBody,
|
|
405
|
+
comments: inlineComments,
|
|
406
|
+
}
|
|
407
|
+
const payload = JSON.stringify(payloadObj)
|
|
408
|
+
|
|
409
|
+
const command = [
|
|
410
|
+
bin,
|
|
411
|
+
'api',
|
|
412
|
+
`repos/{owner}/{repo}/pulls/${input.prNumber}/reviews`,
|
|
413
|
+
'--method',
|
|
414
|
+
'POST',
|
|
415
|
+
'--input',
|
|
416
|
+
'-',
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
if (input.dryRun) {
|
|
420
|
+
return {
|
|
421
|
+
reviewId: null,
|
|
422
|
+
comments: input.findingIds.map((id) => ({ id, status: 'posted' as const })),
|
|
423
|
+
command,
|
|
424
|
+
payload,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Execute.
|
|
429
|
+
let stdoutText: string
|
|
430
|
+
let stderrText: string
|
|
431
|
+
let exit: number
|
|
432
|
+
try {
|
|
433
|
+
const proc = Bun.spawn(command, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
|
|
434
|
+
const stdin = proc.stdin as import('bun').FileSink
|
|
435
|
+
stdin.write(payload)
|
|
436
|
+
await stdin.flush()
|
|
437
|
+
stdin.end()
|
|
438
|
+
;[stdoutText, stderrText, exit] = await Promise.all([
|
|
439
|
+
new Response(proc.stdout as ReadableStream).text(),
|
|
440
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
441
|
+
proc.exited,
|
|
442
|
+
])
|
|
443
|
+
} catch (err) {
|
|
444
|
+
const msg = (err as Error).message ?? `cannot spawn ${bin}`
|
|
445
|
+
return {
|
|
446
|
+
reviewId: null,
|
|
447
|
+
comments: input.findingIds.map((id) => ({ id, status: 'failed' as const, message: msg })),
|
|
448
|
+
command,
|
|
449
|
+
payload,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (exit !== 0) {
|
|
454
|
+
return {
|
|
455
|
+
reviewId: null,
|
|
456
|
+
comments: input.findingIds.map((id) => ({
|
|
457
|
+
id,
|
|
458
|
+
status: 'failed' as const,
|
|
459
|
+
message: stderrText.trim(),
|
|
460
|
+
})),
|
|
461
|
+
command,
|
|
462
|
+
payload,
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const review = JSON.parse(stdoutText) as { id: number }
|
|
467
|
+
const reviewId = String(review.id)
|
|
468
|
+
|
|
469
|
+
// Persist to post-status.json.
|
|
470
|
+
const existing = await readPostStatus(input.runDir)
|
|
471
|
+
for (const id of input.findingIds) {
|
|
472
|
+
existing[id] = 'posted'
|
|
473
|
+
}
|
|
474
|
+
existing.__lastReviewId = reviewId
|
|
475
|
+
await writePostStatus(input.runDir, existing)
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
reviewId,
|
|
479
|
+
comments: input.findingIds.map((id) => ({ id, status: 'posted' as const })),
|
|
480
|
+
command,
|
|
481
|
+
payload,
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
283
485
|
async function readPostStatus(runDir: string): Promise<Record<string, unknown>> {
|
|
284
486
|
try {
|
|
285
487
|
return (await Bun.file(join(runDir, 'post-status.json')).json()) as Record<string, unknown>
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { PostStatusMap } from './render-findings.ts'
|
|
4
|
+
import { renderFindingsToDisk } from './render-findings.ts'
|
|
5
|
+
import type { RenderProgressInput, StageId, StageStatus } from './render-progress.ts'
|
|
6
|
+
import { renderProgressToDisk } from './render-progress.ts'
|
|
7
|
+
import { parseFinding } from './types.ts'
|
|
8
|
+
|
|
9
|
+
export type PreviewPage = 'findings' | 'progress' | 'both'
|
|
10
|
+
|
|
11
|
+
/** Stage presets. Each one is a complete snapshot of what the pipeline page
|
|
12
|
+
* should look like at that point in the run. Keeping them as literal data
|
|
13
|
+
* (instead of synthesizing from a log file) lets a designer iterate on the
|
|
14
|
+
* progress UI without simulating events. */
|
|
15
|
+
export const STAGE_PRESETS = {
|
|
16
|
+
fresh: {
|
|
17
|
+
stages: pending(),
|
|
18
|
+
counts: {},
|
|
19
|
+
},
|
|
20
|
+
'setup-running': {
|
|
21
|
+
stages: { ...pending(), setup: 'running' },
|
|
22
|
+
counts: {},
|
|
23
|
+
},
|
|
24
|
+
'setup-done': {
|
|
25
|
+
stages: { ...pending(), setup: 'done', context: 'running' },
|
|
26
|
+
counts: {},
|
|
27
|
+
},
|
|
28
|
+
'context-skipped': {
|
|
29
|
+
stages: { ...pending(), setup: 'done', context: 'skipped', specialists: 'running' },
|
|
30
|
+
counts: { security: 0, bugs: 1 },
|
|
31
|
+
},
|
|
32
|
+
'specialists-running': {
|
|
33
|
+
// Partial counts coming in while the specialists are mid-flight.
|
|
34
|
+
stages: {
|
|
35
|
+
...pending(),
|
|
36
|
+
setup: 'done',
|
|
37
|
+
context: 'done',
|
|
38
|
+
specialists: 'running',
|
|
39
|
+
},
|
|
40
|
+
counts: { security: 1, bugs: 3, performance: 2, 'code-smells': 0, architecture: 0 },
|
|
41
|
+
},
|
|
42
|
+
'specialists-done': {
|
|
43
|
+
stages: {
|
|
44
|
+
...pending(),
|
|
45
|
+
setup: 'done',
|
|
46
|
+
context: 'done',
|
|
47
|
+
specialists: 'done',
|
|
48
|
+
dedupe: 'running',
|
|
49
|
+
},
|
|
50
|
+
counts: { security: 1, bugs: 4, performance: 2, 'code-smells': 5, architecture: 3 },
|
|
51
|
+
},
|
|
52
|
+
'dedupe-done': {
|
|
53
|
+
stages: {
|
|
54
|
+
...pending(),
|
|
55
|
+
setup: 'done',
|
|
56
|
+
context: 'done',
|
|
57
|
+
specialists: 'done',
|
|
58
|
+
dedupe: 'done',
|
|
59
|
+
critic: 'running',
|
|
60
|
+
},
|
|
61
|
+
counts: { security: 1, bugs: 3, performance: 2, 'code-smells': 4, architecture: 2 },
|
|
62
|
+
},
|
|
63
|
+
'critic-done': {
|
|
64
|
+
stages: {
|
|
65
|
+
...pending(),
|
|
66
|
+
setup: 'done',
|
|
67
|
+
context: 'done',
|
|
68
|
+
specialists: 'done',
|
|
69
|
+
dedupe: 'done',
|
|
70
|
+
critic: 'done',
|
|
71
|
+
'peer-review': 'running',
|
|
72
|
+
},
|
|
73
|
+
counts: { security: 1, bugs: 2, performance: 1, 'code-smells': 3, architecture: 2 },
|
|
74
|
+
},
|
|
75
|
+
'peer-review-error': {
|
|
76
|
+
stages: {
|
|
77
|
+
...pending(),
|
|
78
|
+
setup: 'done',
|
|
79
|
+
context: 'done',
|
|
80
|
+
specialists: 'done',
|
|
81
|
+
dedupe: 'done',
|
|
82
|
+
critic: 'done',
|
|
83
|
+
'peer-review': 'error',
|
|
84
|
+
},
|
|
85
|
+
counts: { security: 1, bugs: 2, performance: 1, 'code-smells': 3, architecture: 2 },
|
|
86
|
+
},
|
|
87
|
+
'report-done': {
|
|
88
|
+
// The default: what a reviewer sees when they open the page after the
|
|
89
|
+
// pipeline finishes but before they post anything.
|
|
90
|
+
stages: {
|
|
91
|
+
...pending(),
|
|
92
|
+
setup: 'done',
|
|
93
|
+
context: 'done',
|
|
94
|
+
specialists: 'done',
|
|
95
|
+
dedupe: 'done',
|
|
96
|
+
critic: 'done',
|
|
97
|
+
'peer-review': 'done',
|
|
98
|
+
report: 'done',
|
|
99
|
+
},
|
|
100
|
+
counts: { security: 1, bugs: 2, performance: 2, 'code-smells': 2, architecture: 1 },
|
|
101
|
+
},
|
|
102
|
+
'post-done': {
|
|
103
|
+
stages: {
|
|
104
|
+
...pending(),
|
|
105
|
+
setup: 'done',
|
|
106
|
+
context: 'done',
|
|
107
|
+
specialists: 'done',
|
|
108
|
+
dedupe: 'done',
|
|
109
|
+
critic: 'done',
|
|
110
|
+
'peer-review': 'done',
|
|
111
|
+
report: 'done',
|
|
112
|
+
post: 'done',
|
|
113
|
+
},
|
|
114
|
+
counts: { security: 1, bugs: 2, performance: 2, 'code-smells': 2, architecture: 1 },
|
|
115
|
+
},
|
|
116
|
+
} as const
|
|
117
|
+
|
|
118
|
+
function pending(): Record<StageId, StageStatus> {
|
|
119
|
+
return {
|
|
120
|
+
setup: 'pending',
|
|
121
|
+
context: 'pending',
|
|
122
|
+
specialists: 'pending',
|
|
123
|
+
dedupe: 'pending',
|
|
124
|
+
critic: 'pending',
|
|
125
|
+
'peer-review': 'pending',
|
|
126
|
+
report: 'pending',
|
|
127
|
+
post: 'pending',
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type StagePreset = keyof typeof STAGE_PRESETS
|
|
132
|
+
|
|
133
|
+
export const DEFAULT_STAGE: StagePreset = 'report-done'
|
|
134
|
+
|
|
135
|
+
export type PreviewOptions = {
|
|
136
|
+
page: PreviewPage
|
|
137
|
+
stage: StagePreset
|
|
138
|
+
/** Fixture directory; defaults to the bundled example-pr. */
|
|
139
|
+
fixtureDir?: string
|
|
140
|
+
/** Output directory; defaults to a timestamped path under ~/.magpie/. */
|
|
141
|
+
outDir?: string
|
|
142
|
+
/** When false, skip the OS `open` call (used by tests and dry runs). */
|
|
143
|
+
openInBrowser?: boolean
|
|
144
|
+
/** When true, do not write files; only resolve and return the planned paths. */
|
|
145
|
+
dryRun?: boolean
|
|
146
|
+
/** Allow tests / CI to inject the opener. Defaults to the system `open` / `xdg-open`. */
|
|
147
|
+
openerFactory?: () => Opener
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type PreviewResult = {
|
|
151
|
+
outDir: string
|
|
152
|
+
findingsHtml?: string
|
|
153
|
+
progressHtml?: string
|
|
154
|
+
opened?: string
|
|
155
|
+
dryRun?: boolean
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type Opener = (filePath: string) => Promise<void>
|
|
159
|
+
|
|
160
|
+
function defaultOpener(): Opener {
|
|
161
|
+
return async (filePath) => {
|
|
162
|
+
const bin = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
163
|
+
const proc = Bun.spawn([bin, filePath], { stdout: 'inherit', stderr: 'inherit' })
|
|
164
|
+
await proc.exited
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function defaultFixtureDir(): string {
|
|
169
|
+
// The fixture lives next to this script, under fixtures/example-pr.
|
|
170
|
+
return new URL('../fixtures/example-pr', import.meta.url).pathname
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function defaultOutDir(): string {
|
|
174
|
+
const home = process.env.HOME || '/tmp'
|
|
175
|
+
return join(home, '.magpie', `preview-${Date.now()}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function readFixture(fixtureDir: string): Promise<{
|
|
179
|
+
pr: { number: number; branch: string; headSha: string }
|
|
180
|
+
findings: ReturnType<typeof parseFinding>[]
|
|
181
|
+
postStatus: PostStatusMap
|
|
182
|
+
files: Array<{ path: string; additions: number; deletions: number }>
|
|
183
|
+
diff: string
|
|
184
|
+
}> {
|
|
185
|
+
const prRaw = JSON.parse(await readFile(join(fixtureDir, 'pr.json'), 'utf8')) as Record<
|
|
186
|
+
string,
|
|
187
|
+
unknown
|
|
188
|
+
>
|
|
189
|
+
const findingsRaw = JSON.parse(
|
|
190
|
+
await readFile(join(fixtureDir, 'findings.final.json'), 'utf8'),
|
|
191
|
+
) as unknown[]
|
|
192
|
+
let postStatusRaw: Record<string, unknown> = {}
|
|
193
|
+
try {
|
|
194
|
+
postStatusRaw = JSON.parse(
|
|
195
|
+
await readFile(join(fixtureDir, 'post-status.json'), 'utf8'),
|
|
196
|
+
) as Record<string, unknown>
|
|
197
|
+
} catch {
|
|
198
|
+
// optional file
|
|
199
|
+
}
|
|
200
|
+
let filesArray: Array<{ path: string; additions: number; deletions: number }> = []
|
|
201
|
+
if (Array.isArray(prRaw.files)) {
|
|
202
|
+
filesArray = (prRaw.files as unknown[]).map((f) => {
|
|
203
|
+
const entry = f as Record<string, unknown>
|
|
204
|
+
return {
|
|
205
|
+
path: String(entry.path ?? ''),
|
|
206
|
+
additions: Number(entry.additions ?? 0),
|
|
207
|
+
deletions: Number(entry.deletions ?? 0),
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
let diff = ''
|
|
212
|
+
try {
|
|
213
|
+
diff = await readFile(join(fixtureDir, 'diff.patch'), 'utf8')
|
|
214
|
+
} catch {
|
|
215
|
+
// optional file
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
pr: {
|
|
219
|
+
number: Number(prRaw.number ?? 0),
|
|
220
|
+
branch: String(prRaw.headRefName ?? '?'),
|
|
221
|
+
headSha: String(prRaw.headRefOid ?? '?'),
|
|
222
|
+
},
|
|
223
|
+
findings: findingsRaw.map((raw) => parseFinding(raw)),
|
|
224
|
+
postStatus: postStatusRaw as PostStatusMap,
|
|
225
|
+
files: filesArray,
|
|
226
|
+
diff,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function progressInput(
|
|
231
|
+
pr: { number: number; branch: string; headSha: string },
|
|
232
|
+
stage: StagePreset,
|
|
233
|
+
): RenderProgressInput {
|
|
234
|
+
const preset = STAGE_PRESETS[stage]
|
|
235
|
+
return {
|
|
236
|
+
prNumber: pr.number,
|
|
237
|
+
branch: pr.branch,
|
|
238
|
+
headSha: pr.headSha,
|
|
239
|
+
stages: preset.stages,
|
|
240
|
+
specialistCounts: preset.counts,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function runPreview(opts: PreviewOptions): Promise<PreviewResult> {
|
|
245
|
+
const fixtureDir = opts.fixtureDir ?? defaultFixtureDir()
|
|
246
|
+
const outDir = opts.outDir ?? defaultOutDir()
|
|
247
|
+
const fixture = await readFixture(fixtureDir)
|
|
248
|
+
const findingsPath = join(outDir, 'findings.html')
|
|
249
|
+
const progressPath = join(outDir, 'progress.html')
|
|
250
|
+
|
|
251
|
+
if (opts.dryRun) {
|
|
252
|
+
return {
|
|
253
|
+
outDir,
|
|
254
|
+
...(opts.page !== 'progress' ? { findingsHtml: findingsPath } : {}),
|
|
255
|
+
...(opts.page !== 'findings' ? { progressHtml: progressPath } : {}),
|
|
256
|
+
dryRun: true,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await mkdir(outDir, { recursive: true })
|
|
261
|
+
|
|
262
|
+
const result: PreviewResult = { outDir }
|
|
263
|
+
|
|
264
|
+
if (opts.page !== 'progress') {
|
|
265
|
+
await renderFindingsToDisk(
|
|
266
|
+
{
|
|
267
|
+
findings: fixture.findings,
|
|
268
|
+
postStatus: fixture.postStatus,
|
|
269
|
+
runId: `preview-${opts.stage}`,
|
|
270
|
+
pr: fixture.pr,
|
|
271
|
+
files: fixture.files,
|
|
272
|
+
diff: fixture.diff,
|
|
273
|
+
},
|
|
274
|
+
findingsPath,
|
|
275
|
+
)
|
|
276
|
+
result.findingsHtml = findingsPath
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (opts.page !== 'findings') {
|
|
280
|
+
await renderProgressToDisk(progressInput(fixture.pr, opts.stage), progressPath)
|
|
281
|
+
result.progressHtml = progressPath
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (opts.openInBrowser !== false) {
|
|
285
|
+
const opener = (opts.openerFactory ?? defaultOpener)()
|
|
286
|
+
// Open findings if requested, else progress.
|
|
287
|
+
const target =
|
|
288
|
+
opts.page === 'progress'
|
|
289
|
+
? progressPath
|
|
290
|
+
: opts.page === 'findings'
|
|
291
|
+
? findingsPath
|
|
292
|
+
: findingsPath
|
|
293
|
+
await opener(target)
|
|
294
|
+
result.opened = target
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return result
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const KNOWN_STAGE_PRESETS = Object.keys(STAGE_PRESETS) as StagePreset[]
|
|
@@ -59,8 +59,23 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
59
59
|
// optional file
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
let pr: { number: number; branch: string; headSha: string } | undefined
|
|
63
|
+
try {
|
|
64
|
+
const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
|
|
65
|
+
const prNumber = Number(prJson.number ?? 0)
|
|
66
|
+
if (prNumber > 0) {
|
|
67
|
+
pr = {
|
|
68
|
+
number: prNumber,
|
|
69
|
+
branch: String(prJson.headRefName ?? '?'),
|
|
70
|
+
headSha: String(prJson.headRefOid ?? '?'),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// optional file; archived runs may not include pr.json
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
await renderFindingsToDisk(
|
|
63
|
-
{ findings, postStatus, runId: basename(runDir) },
|
|
78
|
+
{ findings, postStatus, runId: basename(runDir), pr },
|
|
64
79
|
join(screenDir, 'findings.html'),
|
|
65
80
|
)
|
|
66
81
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isSuggestion, type ReviewFinding, SEVERITIES, type Severity } from './types.ts'
|
|
2
|
+
|
|
3
|
+
const SEVERITY_LABEL: Record<Severity, string> = {
|
|
4
|
+
blocker: 'Blocker',
|
|
5
|
+
high: 'High',
|
|
6
|
+
medium: 'Medium',
|
|
7
|
+
low: 'Low',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function esc(s: string): string {
|
|
11
|
+
return s.replace(/[&<>"']/g, (c) => {
|
|
12
|
+
const m: Record<string, string> = {
|
|
13
|
+
'&': '&',
|
|
14
|
+
'<': '<',
|
|
15
|
+
'>': '>',
|
|
16
|
+
'"': '"',
|
|
17
|
+
"'": ''',
|
|
18
|
+
}
|
|
19
|
+
return m[c] ?? c
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RenderActionBarInput = {
|
|
24
|
+
findings: ReviewFinding[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderActionBar(input: RenderActionBarInput): string {
|
|
28
|
+
const recommended = input.findings.filter((f) => !isSuggestion(f))
|
|
29
|
+
const excluded = input.findings.length - recommended.length
|
|
30
|
+
const counts = new Map<Severity, number>()
|
|
31
|
+
for (const f of recommended) counts.set(f.severity, (counts.get(f.severity) ?? 0) + 1)
|
|
32
|
+
const pills = SEVERITIES.filter((s) => (counts.get(s) ?? 0) > 0)
|
|
33
|
+
.map(
|
|
34
|
+
(s) =>
|
|
35
|
+
`<button type="button" class="action-pill sev-${s}" data-action="select-sev" data-sev="${s}" aria-pressed="false">${esc(SEVERITY_LABEL[s])} (${counts.get(s) ?? 0})</button>`,
|
|
36
|
+
)
|
|
37
|
+
.join('')
|
|
38
|
+
const excludedHint =
|
|
39
|
+
excluded > 0
|
|
40
|
+
? `<span class="excluded">${excluded} suggestion${excluded === 1 ? '' : 's'} excluded</span>`
|
|
41
|
+
: ''
|
|
42
|
+
return `<footer class="action-bar" data-role="action-bar">
|
|
43
|
+
<button type="button" class="link-btn" data-action="select-recommended">Select recommended</button>
|
|
44
|
+
<div class="pills">${pills}</div>
|
|
45
|
+
<div class="spacer"></div>
|
|
46
|
+
<button type="button" class="btn outline" data-action="post-selected" disabled>Post Selected (<span data-role="selected-count">0</span>)</button>
|
|
47
|
+
<button type="button" class="btn primary" data-action="post-recommended">Post Recommended (${recommended.length})</button>
|
|
48
|
+
${excludedHint}
|
|
49
|
+
<p class="post-status" data-role="post-status" role="status" aria-live="polite" hidden></p>
|
|
50
|
+
</footer>`
|
|
51
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { parseFindingDescription } from './finding-description.ts'
|
|
2
|
+
import { isSuggestion, type ReviewFinding } from './types.ts'
|
|
3
|
+
|
|
4
|
+
const DOMAIN_LABELS: Record<string, string> = {
|
|
5
|
+
security: 'Security',
|
|
6
|
+
bugs: 'Bugs',
|
|
7
|
+
performance: 'Perf',
|
|
8
|
+
'code-smells': 'Smells',
|
|
9
|
+
architecture: 'Arch',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SEVERITY_LABEL: Record<string, string> = {
|
|
13
|
+
blocker: 'BLOCKER',
|
|
14
|
+
high: 'HIGH',
|
|
15
|
+
medium: 'MEDIUM',
|
|
16
|
+
low: 'LOW',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function esc(s: string): string {
|
|
20
|
+
return s.replace(/[&<>"']/g, (c) => {
|
|
21
|
+
const m: Record<string, string> = {
|
|
22
|
+
'&': '&',
|
|
23
|
+
'<': '<',
|
|
24
|
+
'>': '>',
|
|
25
|
+
'"': '"',
|
|
26
|
+
"'": ''',
|
|
27
|
+
}
|
|
28
|
+
return m[c] ?? c
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RenderAnnotationOptions = {
|
|
33
|
+
checked: boolean
|
|
34
|
+
posted: boolean
|
|
35
|
+
asCard: boolean
|
|
36
|
+
/** When posting previously failed, surface the failure on the card. */
|
|
37
|
+
failed?: { message: string }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderSections(description: string): string {
|
|
41
|
+
const sections = parseFindingDescription(description)
|
|
42
|
+
if (sections.length === 0) {
|
|
43
|
+
return `<section class="section"><div class="section-label">Observation</div><div class="section-body">${esc(description.trim())}</div></section>`
|
|
44
|
+
}
|
|
45
|
+
return sections
|
|
46
|
+
.map(
|
|
47
|
+
(s) =>
|
|
48
|
+
`<section class="section"><div class="section-label">${esc(s.label)}</div><div class="section-body">${esc(s.body)}</div></section>`,
|
|
49
|
+
)
|
|
50
|
+
.join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderSuggestion(f: ReviewFinding): string {
|
|
54
|
+
if (!f.suggestion) return ''
|
|
55
|
+
return `<section class="section"><div class="section-label">Suggested change</div><pre class="suggestion">${esc(f.suggestion.body)}</pre></section>`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderRisk(f: ReviewFinding): string {
|
|
59
|
+
return `<div class="risk">Impact: <span class="tag">${esc(f.risk.impact)}</span> · Likelihood: <span class="tag">${esc(f.risk.likelihood)}</span> · Confidence: <span class="tag">${esc(f.risk.confidence)}</span> · Action: <span class="tag">${esc(f.risk.action)}</span></div>`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function renderAnnotation(f: ReviewFinding, opts: RenderAnnotationOptions): string {
|
|
63
|
+
const domain = (f.domain as string) ?? 'unknown'
|
|
64
|
+
const domainLabel = DOMAIN_LABELS[domain] ?? domain
|
|
65
|
+
const sevLabel = SEVERITY_LABEL[f.severity] ?? f.severity.toUpperCase()
|
|
66
|
+
const containerClass = opts.asCard ? `issue-card sev-${f.severity}` : `annot sev-${f.severity}`
|
|
67
|
+
const cbAttrs = opts.posted ? 'checked disabled' : opts.checked ? 'checked' : ''
|
|
68
|
+
const suggestion = isSuggestion(f) ? 'true' : 'false'
|
|
69
|
+
const postedAttr = opts.posted ? ' data-posted="true"' : ''
|
|
70
|
+
const failedAttr = opts.failed ? ' data-failed="true"' : ''
|
|
71
|
+
let statusChip: string
|
|
72
|
+
if (opts.posted) {
|
|
73
|
+
statusChip = '<span class="status-chip posted">POSTED</span>'
|
|
74
|
+
} else if (opts.failed) {
|
|
75
|
+
statusChip = `<span class="status-chip failed" title="${esc(opts.failed.message)}">FAILED: ${esc(opts.failed.message)}</span>`
|
|
76
|
+
} else {
|
|
77
|
+
statusChip = '<span class="status-chip new">NEW</span>'
|
|
78
|
+
}
|
|
79
|
+
return `<div class="${containerClass}" data-finding-id="${esc(f.id)}" data-severity="${f.severity}" data-domain="${esc(domain)}" data-suggestion="${suggestion}"${postedAttr}${failedAttr}>
|
|
80
|
+
<div class="annot-row">
|
|
81
|
+
<input type="checkbox" data-finding-id="${esc(f.id)}" ${cbAttrs} aria-label="select ${esc(f.id)}" />
|
|
82
|
+
<div class="annot-body">
|
|
83
|
+
<div class="annot-head">
|
|
84
|
+
<span class="sev-label sev-${f.severity}">${sevLabel}</span>
|
|
85
|
+
<span class="annot-title">${esc(f.title)}</span>
|
|
86
|
+
<span class="domain-chip">${esc(domainLabel)}</span>
|
|
87
|
+
${statusChip}
|
|
88
|
+
</div>
|
|
89
|
+
${renderSections(f.description)}
|
|
90
|
+
${renderSuggestion(f)}
|
|
91
|
+
${renderRisk(f)}
|
|
92
|
+
</div>
|
|
93
|
+
<button type="button" class="send-btn" data-action="post-one" data-finding-id="${esc(f.id)}" title="Post this finding" aria-label="post">▸</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>`
|
|
96
|
+
}
|