@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/skills/magpie/README.md +16 -1
  4. package/skills/magpie/bin/magpie.ts +60 -2
  5. package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
  6. package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
  7. package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
  8. package/skills/magpie/fixtures/example-pr/pr.json +21 -0
  9. package/skills/magpie/fixtures/fake-gh.sh +10 -1
  10. package/skills/magpie/package.json +1 -1
  11. package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
  12. package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
  13. package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
  14. package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
  15. package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
  16. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
  17. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
  18. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
  19. package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
  20. package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
  21. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
  22. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
  23. package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
  24. package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
  25. package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -182
  26. package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
  27. package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -0
  28. package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
  29. package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
  30. package/skills/magpie/scripts/diff-utils.ts +129 -0
  31. package/skills/magpie/scripts/file-tree.ts +74 -0
  32. package/skills/magpie/scripts/gh.ts +1 -0
  33. package/skills/magpie/scripts/helper.js +402 -192
  34. package/skills/magpie/scripts/post-cmd.ts +202 -0
  35. package/skills/magpie/scripts/preview-cmd.ts +300 -0
  36. package/skills/magpie/scripts/refresh.ts +16 -1
  37. package/skills/magpie/scripts/render-action-bar.ts +51 -0
  38. package/skills/magpie/scripts/render-annotation.ts +96 -0
  39. package/skills/magpie/scripts/render-cmd.ts +24 -1
  40. package/skills/magpie/scripts/render-diff.ts +125 -0
  41. package/skills/magpie/scripts/render-file-tree.ts +86 -0
  42. package/skills/magpie/scripts/render-findings.ts +165 -196
  43. package/skills/magpie/scripts/render-issues-list.ts +78 -0
  44. package/skills/magpie/scripts/render-progress.ts +74 -32
  45. package/skills/magpie/scripts/server.ts +43 -0
  46. package/skills/magpie/scripts/types.ts +13 -0
  47. package/skills/magpie/skill.json +4 -2
  48. 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
+ '&': '&amp;',
14
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ '"': '&quot;',
17
+ "'": '&#39;',
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
+ '&': '&amp;',
23
+ '<': '&lt;',
24
+ '>': '&gt;',
25
+ '"': '&quot;',
26
+ "'": '&#39;',
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
+ }