@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
@@ -1,19 +1,34 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { basename } from 'node:path'
3
- import { parseFindingDescription } from './finding-description.ts'
4
- import { type ReviewFinding, SEVERITIES, type Severity } from './types.ts'
5
-
6
- export type PostStatusEntry = 'posted' | { status: 'failed'; message: string }
7
- export type PostStatusMap = Record<string, PostStatusEntry>
3
+ import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
4
+ import { renderActionBar } from './render-action-bar.ts'
5
+ import { renderSplitDiff, renderUnifiedDiff } from './render-diff.ts'
6
+ import { renderFileTree } from './render-file-tree.ts'
7
+ import { renderIssuesList } from './render-issues-list.ts'
8
+ import type { PostStatusMap, PrFileEntry, ReviewFinding } from './types.ts'
9
+
10
+ export type { PostStatusEntry, PostStatusMap } from './types.ts'
11
+
12
+ export type FindingsPrMeta = {
13
+ number: number
14
+ branch: string
15
+ headSha: string
16
+ }
8
17
 
9
18
  export type RenderFindingsInput = {
10
19
  findings: ReviewFinding[]
11
20
  postStatus: PostStatusMap
12
21
  /** Stable id used as the localStorage selection key. Defaults to "unknown" for tests. */
13
22
  runId?: string
23
+ /** Optional PR identity. When present, the header shows PR #N, branch, sha. */
24
+ pr?: FindingsPrMeta
25
+ /** Files changed in the PR, used to build the file tree and diff panes. */
26
+ files?: PrFileEntry[]
27
+ /** Raw unified diff text for the PR. */
28
+ diff?: string
14
29
  }
15
30
 
16
- function escapeHtml(s: string): string {
31
+ function esc(s: string): string {
17
32
  return s.replace(/[&<>"']/g, (c) => {
18
33
  const m: Record<string, string> = {
19
34
  '&': '&amp;',
@@ -22,236 +37,192 @@ function escapeHtml(s: string): string {
22
37
  '"': '&quot;',
23
38
  "'": '&#39;',
24
39
  }
25
- return m[c] as string
40
+ return m[c] ?? c
26
41
  })
27
42
  }
28
43
 
29
- function badge(status: PostStatusEntry | undefined): string {
30
- if (!status) return ''
31
- if (status === 'posted') return '<span class="badge posted">posted</span>'
32
- return `<span class="badge failed">failed: ${escapeHtml(status.message)}</span>`
33
- }
34
-
35
- function severityBreakdown(findings: ReviewFinding[]): string {
36
- const counts: Record<Severity, number> = { blocker: 0, high: 0, medium: 0, low: 0 }
37
- for (const f of findings) counts[f.severity] += 1
38
- return SEVERITIES.filter((k) => counts[k] > 0)
39
- .map((k) => `<span class="num">${counts[k]}</span>&nbsp;${k}`)
40
- .join(' &nbsp;·&nbsp; ')
41
- }
44
+ const BRAND_MARK_SVG = `<svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true" fill="none">
45
+ <path d="M3 17.5 C 6.5 7.5, 13 6, 17.5 11 L 21 16.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
46
+ <path d="M11.5 12.5 L 21 16.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
47
+ <circle class="magpie-eye" cx="6.5" cy="13.5" r="1.1"/>
48
+ </svg>`
42
49
 
43
- function countBy<T extends string>(
44
- findings: ReviewFinding[],
45
- pick: (f: ReviewFinding) => T,
46
- ): Map<T, number> {
47
- const m = new Map<T, number>()
48
- for (const f of findings) {
49
- const k = pick(f)
50
- m.set(k, (m.get(k) ?? 0) + 1)
51
- }
52
- return m
50
+ function brandBlock(): string {
51
+ return `<div class="brand">${BRAND_MARK_SVG}<span class="brand-text">magpie</span></div>`
53
52
  }
54
53
 
55
- function filterChip(
56
- group: 'sev' | 'domain',
57
- value: string,
58
- count: number,
59
- label: string,
60
- severity?: Severity,
61
- ): string {
62
- const sevAttr = severity ? ` data-severity="${severity}"` : ''
63
- return `<button type="button" class="filter-chip" data-filter-group="${group}" data-filter-value="${escapeHtml(value)}"${sevAttr} aria-pressed="false">
64
- <span class="filter-chip-label">${escapeHtml(label)}</span>
65
- <span class="filter-chip-count">${count}</span>
66
- </button>`
54
+ function prHeader(input: RenderFindingsInput): string {
55
+ const pr = input.pr
56
+ const prMeta = pr
57
+ ? `<div class="pr-meta">
58
+ <span class="pr-number">PR #${pr.number}</span>
59
+ <span class="pr-branch">${esc(pr.branch)}</span>
60
+ <code>${esc(pr.headSha.slice(0, 12))}</code>
61
+ </div>`
62
+ : `<div class="pr-meta"></div>`
63
+ const total = input.findings.length
64
+ return `<header class="pr-header">
65
+ ${brandBlock()}
66
+ ${prMeta}
67
+ <div class="header-spacer"></div>
68
+ <div class="segmented" data-role="tabs">
69
+ <button type="button" data-action="set-view" data-view="files" aria-pressed="true">Files</button>
70
+ <button type="button" data-action="set-view" data-view="all-issues" aria-pressed="false">All Issues <span class="seg-count">(${total})</span></button>
71
+ </div>
72
+ </header>`
67
73
  }
68
74
 
69
- function filterBar(findings: ReviewFinding[]): string {
70
- const sevCounts = countBy(findings, (f) => f.severity)
71
- const domainCounts = countBy(findings, (f) => (f.domain as string) ?? 'unknown')
72
- const sevChips = SEVERITIES.filter((s) => (sevCounts.get(s) ?? 0) > 0)
73
- .map((s) => filterChip('sev', s, sevCounts.get(s) ?? 0, s, s))
74
- .join('')
75
- const domainChips = Array.from(domainCounts.entries())
76
- .sort((a, b) => b[1] - a[1])
77
- .map(([d, n]) => filterChip('domain', d, n, d))
78
- .join('')
79
-
80
- return `<section class="filter-bar" aria-label="filter and search">
81
- <div class="search-row">
82
- <label class="search-field">
83
- <span class="search-icon" aria-hidden="true">⌕</span>
84
- <input
85
- type="search"
86
- data-role="search"
87
- placeholder="search title, file, or description"
88
- aria-label="search findings"
89
- autocomplete="off"
90
- spellcheck="false"
91
- />
92
- <kbd class="search-hint">/</kbd>
93
- </label>
94
- </div>
95
- <div class="chip-row" data-role="filter-row" data-group="sev">
96
- <span class="chip-row-label">severity</span>
97
- ${sevChips}
98
- </div>
99
- <div class="chip-row" data-role="filter-row" data-group="domain">
100
- <span class="chip-row-label">domain</span>
101
- ${domainChips}
102
- </div>
103
- <div class="filter-state">
104
- <span data-role="filter-status">showing all <span class="num" data-role="visible-count">${findings.length}</span> of <span class="num">${findings.length}</span></span>
105
- <button type="button" class="link-btn" data-action="clear-filters" hidden>clear filters</button>
75
+ function filePane(opts: {
76
+ file: PrFileEntry
77
+ fileDiff: string
78
+ findings: ReviewFinding[]
79
+ postStatus: PostStatusMap
80
+ selectedIds: Set<string>
81
+ }): string {
82
+ const hunks = parseUnifiedDiffToHunks(opts.fileDiff)
83
+ const unified = renderUnifiedDiff({
84
+ hunks,
85
+ findings: opts.findings,
86
+ postStatus: opts.postStatus,
87
+ selectedIds: opts.selectedIds,
88
+ })
89
+ const split = renderSplitDiff({
90
+ hunks,
91
+ findings: opts.findings,
92
+ postStatus: opts.postStatus,
93
+ selectedIds: opts.selectedIds,
94
+ })
95
+ const findingsCount = opts.findings.length
96
+ // Findings can't be placed inline when (a) the file isn't in the diff at all
97
+ // (gh truncated, or the diff endpoint didn't include it), or (b) the
98
+ // finding's line doesn't fall inside any visible hunk. Surface them in a
99
+ // banner above the diff so they don't silently vanish.
100
+ const hunkNewLines = new Set<number>()
101
+ for (const h of hunks)
102
+ for (const l of h.lines) if (l.newLineNo != null) hunkNewLines.add(l.newLineNo)
103
+ const unplaced = opts.findings.filter((f) => f.line == null || !hunkNewLines.has(f.line))
104
+ const unplacedBanner =
105
+ unplaced.length > 0
106
+ ? `<div class="unplaced-banner" role="note">
107
+ <div class="unplaced-banner-head">
108
+ <span class="unplaced-banner-label">${unplaced.length} finding${unplaced.length === 1 ? '' : 's'} not anchored to the diff</span>
109
+ <span class="unplaced-banner-hint">${hunks.length === 0 ? 'this file is not in the PR diff snapshot' : 'line falls outside the visible hunks'}</span>
110
+ </div>
111
+ ${renderIssuesList({ findings: unplaced, postStatus: opts.postStatus, selectedIds: opts.selectedIds })}
112
+ </div>`
113
+ : ''
114
+ return `<section class="file-pane" data-file-pane="${esc(opts.file.path)}" hidden>
115
+ <div class="toolbar">
116
+ <div class="crumb">${esc(opts.file.path)}</div>
117
+ <span class="delta"><span class="add">+${opts.file.additions}</span> <span class="del">-${opts.file.deletions}</span></span>
118
+ <div class="finding-nav" data-role="finding-nav" data-total="${findingsCount}">
119
+ <button type="button" data-action="prev-finding" aria-label="previous finding">&#9650;</button>
120
+ <span class="nav-num" data-role="nav-num">${findingsCount} findings</span>
121
+ <button type="button" data-action="next-finding" aria-label="next finding">&#9660;</button>
122
+ </div>
123
+ <div class="diff-mode" data-role="diff-mode">
124
+ <button type="button" data-action="set-diff-mode" data-mode="unified" aria-pressed="true">Unified</button>
125
+ <button type="button" data-action="set-diff-mode" data-mode="split" aria-pressed="false">Split</button>
126
+ </div>
106
127
  </div>
128
+ ${unplacedBanner}
129
+ <div class="diff-unified" data-diff-mode="unified">${unified}</div>
130
+ <div class="diff-split" data-diff-mode="split" hidden>${split}</div>
107
131
  </section>`
108
132
  }
109
133
 
110
- function findingSections(description: string): string {
111
- const sections = parseFindingDescription(description)
112
- if (sections.length === 0) return ''
113
- return sections
114
- .map(
115
- (s) => `<section class="finding-section finding-section-${s.kind}">
116
- <h4 class="finding-section-label">${escapeHtml(s.label)}</h4>
117
- <p class="finding-section-body">${escapeHtml(s.body)}</p>
118
- </section>`,
119
- )
120
- .join('\n ')
121
- }
122
-
123
- function findingRiskFooter(f: ReviewFinding): string {
124
- return `<footer class="finding-risk" aria-label="risk breakdown">
125
- <span>Impact <strong>${escapeHtml(f.risk.impact)}</strong></span>
126
- <span>Likelihood <strong>${escapeHtml(f.risk.likelihood)}</strong></span>
127
- <span>Confidence <strong>${escapeHtml(f.risk.confidence)}</strong></span>
128
- <span>Action <strong>${escapeHtml(f.risk.action)}</strong></span>
129
- </footer>`
130
- }
131
-
132
- function findingSuggestion(f: ReviewFinding): string {
133
- if (!f.suggestion) return ''
134
- return `<section class="finding-suggestion-wrap">
135
- <h4 class="finding-section-label">Suggested change</h4>
136
- <pre class="finding-suggestion">${escapeHtml(f.suggestion.body)}</pre>
134
+ function overviewPane(opts: {
135
+ findings: ReviewFinding[]
136
+ postStatus: PostStatusMap
137
+ selectedIds: Set<string>
138
+ }): string {
139
+ const general = opts.findings.filter((f) => !f.file)
140
+ if (general.length === 0) {
141
+ return `<section class="overview-pane" data-file-pane="">
142
+ <div class="empty">No general findings. Pick a file from the rail.</div>
143
+ </section>`
144
+ }
145
+ return `<section class="overview-pane" data-file-pane="">
146
+ ${renderIssuesList({ findings: general, postStatus: opts.postStatus, selectedIds: opts.selectedIds })}
137
147
  </section>`
138
148
  }
139
149
 
140
- function findingCard(f: ReviewFinding, status: PostStatusEntry | undefined): string {
141
- const checked = status === 'posted' ? 'checked disabled' : ''
142
- const anchor = f.line != null ? `${escapeHtml(f.file)}:${f.line}` : escapeHtml(f.file)
143
- const domain = (f.domain as string) ?? 'unknown'
144
- return `<article class="finding" id="finding-${escapeHtml(f.id)}" tabindex="-1"
145
- data-severity="${f.severity}"
146
- data-domain="${escapeHtml(domain)}"
147
- data-file="${escapeHtml(f.file)}"
148
- data-search-text="${escapeHtml(`${f.title} ${f.description} ${f.file}`.toLowerCase())}">
149
- <header class="finding-head">
150
- <input type="checkbox" data-finding-id="${escapeHtml(f.id)}" ${checked} aria-label="select finding ${escapeHtml(f.id)}" />
151
- <span class="sev-chip sev-${f.severity}">${f.severity}</span>
152
- <h3 class="finding-title">${escapeHtml(f.title)}</h3>
153
- ${badge(status)}
154
- </header>
155
- <div class="finding-anchor"><span class="path">${anchor}</span><span class="domain">${escapeHtml(domain)}</span></div>
156
- <div class="finding-body">
157
- ${findingSections(f.description)}
158
- ${findingSuggestion(f)}
159
- </div>
160
- ${findingRiskFooter(f)}
161
- </article>`
162
- }
163
-
164
- const ARCHIVED_BANNER = `<div class="archived-banner" role="note"><strong>Archived view.</strong> The live server is gone; selections are read-only. Run <code>magpie serve &lt;run-dir&gt;</code> to bring it back.</div>`
165
-
166
- const SHORTCUTS_HINT = `<aside class="kbd-hint" aria-label="keyboard shortcuts">
167
- <kbd>j</kbd>/<kbd>k</kbd> navigate
168
- <kbd>x</kbd> toggle
169
- <kbd>a</kbd> all visible
170
- <kbd>n</kbd> clear
171
- <kbd>/</kbd> search
172
- <kbd>Esc</kbd> reset
173
- </aside>`
174
-
175
150
  export function renderFindingsHtml(input: RenderFindingsInput): string {
176
- const { findings, postStatus } = input
177
151
  const runId = input.runId ?? 'unknown'
152
+ const files = input.files ?? []
153
+ const diff = input.diff ?? ''
154
+ const splitDiffs = splitDiffByFile(diff)
155
+ const selectedIds = new Set<string>()
178
156
 
179
- if (findings.length === 0) {
157
+ if (input.findings.length === 0) {
180
158
  return `<!DOCTYPE html>
181
159
  <html lang="en">
182
160
  <head>
183
161
  <meta charset="utf-8">
184
162
  <meta name="color-scheme" content="light dark">
185
- <title>magpie — no findings</title>
163
+ <title>magpie · no findings</title>
186
164
  <link rel="stylesheet" href="data:text/css;base64,STYLES_INLINE">
187
165
  </head>
188
- <body data-run-id="${escapeHtml(runId)}">
189
- ${ARCHIVED_BANNER}
190
- <main class="page">
191
- <section class="empty-state">
192
- <h1>No findings</h1>
193
- <p class="lede">All specialists returned cleanly and nothing survived the pipeline.</p>
194
- </section>
166
+ <body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
167
+ ${prHeader(input)}
168
+ <main class="page-main">
169
+ <div class="empty-state"><h1>No findings</h1><p>All specialists returned cleanly.</p></div>
195
170
  </main>
196
171
  <script>HELPER_INLINE</script>
197
172
  </body>
198
173
  </html>`
199
174
  }
200
175
 
201
- const cards = findings.map((f) => findingCard(f, postStatus[f.id])).join('\n')
202
- const breakdown = severityBreakdown(findings)
176
+ const filePanesHtml = files
177
+ .map((file) => {
178
+ const fileDiff = splitDiffs.get(file.path) ?? ''
179
+ const findingsInFile = input.findings.filter((f) => f.file === file.path)
180
+ return filePane({
181
+ file,
182
+ fileDiff,
183
+ findings: findingsInFile,
184
+ postStatus: input.postStatus,
185
+ selectedIds,
186
+ })
187
+ })
188
+ .join('\n')
189
+
190
+ const overviewHtml = overviewPane({
191
+ findings: input.findings,
192
+ postStatus: input.postStatus,
193
+ selectedIds,
194
+ })
195
+ const tree = renderFileTree({ files, findings: input.findings })
196
+ const issues = renderIssuesList({
197
+ findings: input.findings,
198
+ postStatus: input.postStatus,
199
+ selectedIds,
200
+ })
201
+ const actionBar = renderActionBar({ findings: input.findings })
203
202
 
204
203
  return `<!DOCTYPE html>
205
204
  <html lang="en">
206
205
  <head>
207
206
  <meta charset="utf-8">
208
207
  <meta name="color-scheme" content="light dark">
209
- <title>magpie — findings</title>
208
+ <title>magpie · findings</title>
210
209
  <link rel="stylesheet" href="data:text/css;base64,STYLES_INLINE">
211
210
  </head>
212
- <body data-run-id="${escapeHtml(runId)}">
213
- ${ARCHIVED_BANNER}
214
- <main class="page">
215
- <header class="page-header">
216
- <p class="eyebrow">magpie · findings</p>
217
- <h1>Review</h1>
218
- <p class="lede">Pick what's worth posting and click <strong>Post to PR</strong>. The server runs <code>gh</code> for each selected finding and updates the badges in place.</p>
219
- </header>
220
-
221
- <section class="findings-summary" aria-label="summary">
222
- <div><span class="count">${findings.length}</span> <span class="subtle">finding${findings.length === 1 ? '' : 's'}</span></div>
223
- <div class="breakdown">${breakdown}</div>
224
- </section>
225
-
226
- ${filterBar(findings)}
227
-
228
- <section class="findings-list" data-role="findings-list">
229
- ${cards}
230
- <div class="no-matches" data-role="no-matches" hidden>No findings match the current filters.</div>
231
- </section>
232
-
233
- ${SHORTCUTS_HINT}
234
-
235
- <div class="submit-bar">
236
- <span class="selected-count" data-role="selected-count"><span class="num">0</span> selected</span>
237
- <div class="bulk-actions">
238
- <button type="button" class="link-btn" data-action="select-visible">all visible</button>
239
- <button type="button" class="link-btn" data-action="select-priority">blockers + highs</button>
240
- <button type="button" class="link-btn" data-action="select-none">clear</button>
241
- </div>
242
- <div class="post-button-group">
243
- <button class="submit-btn" data-action="post" disabled>Post to PR</button>
244
- <div class="confirm-popup" data-role="confirm-bar" role="dialog" aria-label="confirm post" hidden>
245
- <p class="confirm-text" data-role="confirm-text"></p>
246
- <div class="confirm-actions">
247
- <button type="button" class="link-btn" data-action="cancel-post">Cancel</button>
248
- <button type="button" class="submit-btn confirm-btn" data-action="confirm-post">Confirm post</button>
249
- </div>
250
- </div>
211
+ <body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
212
+ ${prHeader(input)}
213
+ <main class="page-main">
214
+ <div class="view files-view">
215
+ ${tree}
216
+ <div class="diff-pane" data-role="diff-pane">
217
+ ${overviewHtml}
218
+ ${filePanesHtml}
251
219
  </div>
252
220
  </div>
253
- <p class="submit-status" data-role="submit-status" role="status" aria-live="polite" hidden></p>
221
+ <div class="view issues-view">
222
+ ${issues}
223
+ </div>
254
224
  </main>
225
+ ${actionBar}
255
226
  <script>HELPER_INLINE</script>
256
227
  </body>
257
228
  </html>`
@@ -265,8 +236,6 @@ export async function renderFindingsToDisk(
265
236
  const helperPath = new URL('./helper.js', import.meta.url).pathname
266
237
  const css = await readFile(stylesPath, 'utf8')
267
238
  const helper = await readFile(helperPath, 'utf8')
268
- // Derive runId from the screen output path when caller didn't supply one.
269
- // out path is .../<runId>/screen/findings*.html — three segments up = runId.
270
239
  const derived = basename(outPath.replace(/\/screen\/.+$/, ''))
271
240
  const html = renderFindingsHtml({ ...input, runId: input.runId ?? derived })
272
241
  .replace(
@@ -0,0 +1,78 @@
1
+ import { renderAnnotation } from './render-annotation.ts'
2
+ import type { PostStatusMap } from './types.ts'
3
+ import { isSuggestion, type ReviewFinding, SEVERITIES, type Severity } from './types.ts'
4
+
5
+ const SEVERITY_LABEL: Record<Severity, string> = {
6
+ blocker: 'Blocker',
7
+ high: 'High',
8
+ medium: 'Medium',
9
+ low: 'Low',
10
+ }
11
+
12
+ function esc(s: string): string {
13
+ return s.replace(/[&<>"']/g, (c) => {
14
+ const m: Record<string, string> = {
15
+ '&': '&amp;',
16
+ '<': '&lt;',
17
+ '>': '&gt;',
18
+ '"': '&quot;',
19
+ "'": '&#39;',
20
+ }
21
+ return m[c] ?? c
22
+ })
23
+ }
24
+
25
+ export type RenderIssuesListInput = {
26
+ findings: ReviewFinding[]
27
+ postStatus: PostStatusMap
28
+ selectedIds: Set<string>
29
+ }
30
+
31
+ function severityCounts(findings: ReviewFinding[]): Map<Severity, number> {
32
+ const m = new Map<Severity, number>()
33
+ for (const f of findings) m.set(f.severity, (m.get(f.severity) ?? 0) + 1)
34
+ return m
35
+ }
36
+
37
+ export function renderIssuesList(input: RenderIssuesListInput): string {
38
+ const { findings, postStatus, selectedIds } = input
39
+ const actionable = findings.filter((f) => !isSuggestion(f))
40
+ const suggestions = findings.filter((f) => isSuggestion(f))
41
+ const counts = severityCounts(findings)
42
+ const pillsHtml = SEVERITIES.filter((s) => (counts.get(s) ?? 0) > 0)
43
+ .map(
44
+ (s) =>
45
+ `<button type="button" class="filter-pill sev-${s}" data-action="filter-sev" data-sev="${s}" aria-pressed="true">${esc(SEVERITY_LABEL[s])} (${counts.get(s) ?? 0})</button>`,
46
+ )
47
+ .join('')
48
+ const suggestionToggle =
49
+ suggestions.length > 0
50
+ ? `<button type="button" class="show-suggestions-toggle" data-action="toggle-suggestions" aria-pressed="false">Show ${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}</button>`
51
+ : ''
52
+ const cardsHtml = findings
53
+ .map((f) => {
54
+ const status = postStatus[f.id]
55
+ const failed =
56
+ status && typeof status === 'object' && status.status === 'failed'
57
+ ? { message: status.message }
58
+ : undefined
59
+ return renderAnnotation(f, {
60
+ checked: selectedIds.has(f.id),
61
+ posted: status === 'posted',
62
+ failed,
63
+ asCard: true,
64
+ })
65
+ })
66
+ .join('\n')
67
+ return `<section class="issues-pane" data-role="issues-list">
68
+ <div class="issues-filter">
69
+ <span class="lead">${actionable.length} should review</span>
70
+ ${suggestionToggle}
71
+ <span class="sep"></span>
72
+ ${pillsHtml}
73
+ </div>
74
+ <div class="issues-list">
75
+ ${cardsHtml}
76
+ </div>
77
+ </section>`
78
+ }