@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
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
31
|
+
function esc(s: string): string {
|
|
17
32
|
return s.replace(/[&<>"']/g, (c) => {
|
|
18
33
|
const m: Record<string, string> = {
|
|
19
34
|
'&': '&',
|
|
@@ -22,236 +37,192 @@ function escapeHtml(s: string): string {
|
|
|
22
37
|
'"': '"',
|
|
23
38
|
"'": ''',
|
|
24
39
|
}
|
|
25
|
-
return m[c]
|
|
40
|
+
return m[c] ?? c
|
|
26
41
|
})
|
|
27
42
|
}
|
|
28
43
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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> ${k}`)
|
|
40
|
-
.join(' · ')
|
|
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
|
|
44
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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">▲</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">▼</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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 <run-dir></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
|
|
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="${
|
|
189
|
-
${
|
|
190
|
-
<main class="page">
|
|
191
|
-
<
|
|
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
|
|
202
|
-
|
|
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
|
|
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="${
|
|
213
|
-
${
|
|
214
|
-
<main class="page">
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
<
|
|
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
|
+
'&': '&',
|
|
16
|
+
'<': '<',
|
|
17
|
+
'>': '>',
|
|
18
|
+
'"': '"',
|
|
19
|
+
"'": ''',
|
|
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
|
+
}
|