@payconductor/houndsman 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/index.ts +31 -21
- package/src/core/commands/findings.ts +137 -11
- package/src/core/findings-state.ts +19 -0
- package/src/core/github-review.ts +452 -0
- package/src/server/client/App.tsx +174 -49
- package/src/server/client/ArtifactsPanel.tsx +2 -2
- package/src/server/client/AutoRefresh.tsx +38 -0
- package/src/server/client/Filters.tsx +72 -53
- package/src/server/client/FindingCard.tsx +212 -71
- package/src/server/client/FindingsList.tsx +8 -6
- package/src/server/client/RunList.tsx +30 -7
- package/src/server/client/SubmitModal.tsx +418 -0
- package/src/server/client/SummaryBar.tsx +65 -12
- package/src/server/client/constants.ts +9 -12
- package/src/server/client/format.ts +40 -0
- package/src/server/client/icons.tsx +143 -0
- package/src/server/client/types.ts +69 -0
- package/src/server/index.ts +352 -88
- package/src/tests/__snapshots__/baseline.test.ts.snap +0 -0
- package/src/tests/baseline.test.ts +493 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/server.test.ts +117 -0
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cartão de um finding:
|
|
2
|
+
* Cartão de um finding: identidade (severidade + local), ação recomendada,
|
|
3
|
+
* evidência, watchdogs, decisão segmentada e comentário com auto-save.
|
|
4
|
+
*
|
|
5
|
+
* Colapso: marcar como feito recolhe o cartão (título tachado) para limpar a
|
|
6
|
+
* poluição visual da triagem; reabrir volta a expandir.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
import { useCallback, useEffect, useState } from "preact/hooks";
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
6
10
|
import { api } from "./api";
|
|
7
|
-
import { DECISION_LABELS, DECISIONS,
|
|
11
|
+
import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS } from "./constants";
|
|
12
|
+
import { vscodeFileLink } from "./format";
|
|
13
|
+
import {
|
|
14
|
+
Check,
|
|
15
|
+
ChevronRight,
|
|
16
|
+
CodeIcon,
|
|
17
|
+
ExternalLink,
|
|
18
|
+
Sparkles,
|
|
19
|
+
Spinner,
|
|
20
|
+
Watch,
|
|
21
|
+
} from "./icons";
|
|
8
22
|
import type { Finding } from "./types";
|
|
9
23
|
|
|
24
|
+
/** Debounce do auto-save do comentário (ms). */
|
|
25
|
+
const AUTOSAVE_MS = 700;
|
|
26
|
+
/** Tempo que o "✓ Salvo" fica visível antes de voltar ao estado neutro (ms). */
|
|
27
|
+
const SAVED_VISIBLE_MS = 1800;
|
|
28
|
+
|
|
10
29
|
export function FindingCard({
|
|
11
30
|
slug,
|
|
12
31
|
finding,
|
|
@@ -16,13 +35,33 @@ export function FindingCard({
|
|
|
16
35
|
finding: Finding;
|
|
17
36
|
onUpdated: (finding: Finding) => void;
|
|
18
37
|
}) {
|
|
19
|
-
const
|
|
38
|
+
const isDone = finding.execState === "done";
|
|
39
|
+
const [open, setOpen] = useState(!isDone);
|
|
20
40
|
const [busy, setBusy] = useState(false);
|
|
41
|
+
const [comment, setComment] = useState(finding.comment ?? "");
|
|
42
|
+
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
|
|
43
|
+
/** Último valor confirmado pelo servidor — evita sobrescrever o que foi digitado. */
|
|
44
|
+
const savedRef = useRef(finding.comment ?? "");
|
|
45
|
+
|
|
46
|
+
// O cartão acompanha o estado de execução: feito colapsa, reaberto expande.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setOpen(finding.execState !== "done" && finding.decision !== "ignore");
|
|
49
|
+
}, [isDone]);
|
|
21
50
|
|
|
51
|
+
// Só reidrata o comentário quando o servidor tem um valor novo e nada local pendente.
|
|
22
52
|
useEffect(() => {
|
|
23
|
-
|
|
53
|
+
const incoming = finding.comment ?? "";
|
|
54
|
+
if (incoming === savedRef.current) return;
|
|
55
|
+
savedRef.current = incoming;
|
|
56
|
+
setComment(incoming);
|
|
24
57
|
}, [finding.comment]);
|
|
25
58
|
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (saveState !== "saved") return;
|
|
61
|
+
const id = setTimeout(() => setSaveState("idle"), SAVED_VISIBLE_MS);
|
|
62
|
+
return () => clearTimeout(id);
|
|
63
|
+
}, [saveState]);
|
|
64
|
+
|
|
26
65
|
const patch = useCallback(
|
|
27
66
|
async (body: Record<string, unknown>) => {
|
|
28
67
|
setBusy(true);
|
|
@@ -36,6 +75,7 @@ export function FindingCard({
|
|
|
36
75
|
},
|
|
37
76
|
);
|
|
38
77
|
onUpdated(updated);
|
|
78
|
+
return updated;
|
|
39
79
|
} finally {
|
|
40
80
|
setBusy(false);
|
|
41
81
|
}
|
|
@@ -43,80 +83,181 @@ export function FindingCard({
|
|
|
43
83
|
[finding.id, onUpdated, slug],
|
|
44
84
|
);
|
|
45
85
|
|
|
86
|
+
// Auto-save do comentário (debounce): nada a salvar = nada a fazer.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const value = comment.trim();
|
|
89
|
+
if (value === savedRef.current.trim()) return;
|
|
90
|
+
setSaveState("saving");
|
|
91
|
+
const id = setTimeout(async () => {
|
|
92
|
+
try {
|
|
93
|
+
await patch({ comment: value });
|
|
94
|
+
savedRef.current = value;
|
|
95
|
+
setSaveState("saved");
|
|
96
|
+
} catch {
|
|
97
|
+
setSaveState("idle");
|
|
98
|
+
}
|
|
99
|
+
}, AUTOSAVE_MS);
|
|
100
|
+
return () => clearTimeout(id);
|
|
101
|
+
}, [comment, patch]);
|
|
102
|
+
|
|
46
103
|
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
47
|
-
const
|
|
48
|
-
const
|
|
104
|
+
const href = finding.absFile ? vscodeFileLink(finding.absFile, finding.line) : null;
|
|
105
|
+
const toggle = () => setOpen((current) => !current);
|
|
49
106
|
|
|
50
107
|
return (
|
|
51
108
|
<article
|
|
52
|
-
class={`card sev-${finding.severity}${finding.present ? "" : " absent"}
|
|
109
|
+
class={`card sev-${finding.severity}${finding.present ? "" : " absent"}${
|
|
110
|
+
isDone ? " done" : ""
|
|
111
|
+
}`}
|
|
53
112
|
>
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
113
|
+
<div class="card-head">
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class={`chev${open ? " open" : ""}`}
|
|
117
|
+
aria-expanded={open}
|
|
118
|
+
title={open ? "Recolher finding" : "Expandir finding"}
|
|
119
|
+
onClick={toggle}
|
|
120
|
+
>
|
|
121
|
+
<ChevronRight size={16} />
|
|
122
|
+
</button>
|
|
123
|
+
<span class="sev-badge">{SEVERITY_LABELS[finding.severity]}</span>
|
|
124
|
+
{href ? (
|
|
125
|
+
<a class="loc" href={href} title={`Abrir ${location} no VS Code`}>
|
|
126
|
+
<CodeIcon size={13} />
|
|
127
|
+
{location}
|
|
128
|
+
<ExternalLink size={12} class="ext" />
|
|
129
|
+
</a>
|
|
130
|
+
) : (
|
|
131
|
+
<code class="loc static" title={location}>
|
|
132
|
+
<CodeIcon size={13} />
|
|
133
|
+
{location}
|
|
134
|
+
</code>
|
|
135
|
+
)}
|
|
136
|
+
{finding.count > 1 && (
|
|
137
|
+
<span class="count" title="ocorrências idênticas colapsadas">
|
|
138
|
+
×{finding.count}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
{!finding.present && (
|
|
142
|
+
<span class="tag" title="Não aparece no último relatório consolidado">
|
|
143
|
+
ausente
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
{finding.github &&
|
|
147
|
+
(finding.github.url ? (
|
|
148
|
+
<a
|
|
149
|
+
class="sent-tag"
|
|
150
|
+
href={finding.github.url}
|
|
151
|
+
target="_blank"
|
|
152
|
+
rel="noreferrer"
|
|
153
|
+
title="Comentário enviado à PR"
|
|
154
|
+
>
|
|
155
|
+
<ExternalLink size={12} /> enviado
|
|
156
|
+
</a>
|
|
157
|
+
) : (
|
|
158
|
+
<span class="sent-tag" title="Comentário enviado à PR">
|
|
159
|
+
<ExternalLink size={12} /> enviado
|
|
160
|
+
</span>
|
|
161
|
+
))}
|
|
60
162
|
<span class="spacer" />
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{DECISIONS.map((decision) => (
|
|
77
|
-
<button
|
|
78
|
-
type="button"
|
|
79
|
-
title={`Marcar como ${DECISION_LABELS[decision]}`}
|
|
80
|
-
class={`btn dec-${decision}${finding.decision === decision ? " active" : ""}`}
|
|
81
|
-
disabled={busy}
|
|
82
|
-
onClick={() =>
|
|
83
|
-
patch({ decision, comment: comment.trim() || undefined })
|
|
84
|
-
}
|
|
85
|
-
>
|
|
86
|
-
{DECISION_LABELS[decision]}
|
|
87
|
-
</button>
|
|
88
|
-
))}
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class={`done-btn${isDone ? " is-done" : ""}`}
|
|
166
|
+
disabled={busy}
|
|
167
|
+
title={isDone ? "Reabrir este finding" : "Marcar como feito"}
|
|
168
|
+
onClick={() => void patch({ execState: isDone ? "pending" : "done" })}
|
|
169
|
+
>
|
|
170
|
+
{isDone ? (
|
|
171
|
+
<>
|
|
172
|
+
<Check size={14} /> Feito
|
|
173
|
+
</>
|
|
174
|
+
) : (
|
|
175
|
+
"Marcar como feito"
|
|
176
|
+
)}
|
|
177
|
+
</button>
|
|
89
178
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
179
|
+
|
|
180
|
+
<h3 class="card-title">
|
|
181
|
+
{open ? finding.message : truncateText(finding.message, 225)}
|
|
182
|
+
</h3>
|
|
183
|
+
|
|
184
|
+
{open && (
|
|
185
|
+
<div class="card-body">
|
|
186
|
+
<div class="meta-row">{finding.rule}</div>
|
|
187
|
+
|
|
188
|
+
<div class="action-block">
|
|
189
|
+
<p>
|
|
190
|
+
<Sparkles size={16} class="action-icon" />
|
|
191
|
+
<span class="action-label">Ação:</span>
|
|
192
|
+
{finding.action}
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{finding.evidence && <pre class="evidence">{finding.evidence}</pre>}
|
|
197
|
+
|
|
198
|
+
<div class="decision-tags-block">
|
|
199
|
+
<div class="decisions">
|
|
200
|
+
{DECISIONS.map((decision) => (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
title={`Marcar como ${DECISION_LABELS[decision]}`}
|
|
204
|
+
class={`dec-btn dec-${decision}${
|
|
205
|
+
finding.decision === decision ? " active" : ""
|
|
206
|
+
}`}
|
|
207
|
+
disabled={busy}
|
|
208
|
+
onClick={() => void patch({ decision })}
|
|
209
|
+
>
|
|
210
|
+
{DECISION_LABELS[decision]}
|
|
211
|
+
</button>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<span class="meta">
|
|
216
|
+
<span class="meta-k">exec</span>
|
|
217
|
+
<code>{finding.execState}</code>
|
|
218
|
+
</span>
|
|
219
|
+
|
|
220
|
+
<div class="tags">
|
|
221
|
+
<span class="tags-label">
|
|
222
|
+
<Watch size={13} /> Watchdogs
|
|
223
|
+
</span>
|
|
224
|
+
{(finding.watchdogIds.length > 0
|
|
225
|
+
? finding.watchdogIds
|
|
226
|
+
: ["—"]
|
|
227
|
+
).map((id) => (
|
|
228
|
+
<span class="tag" key={id}>
|
|
229
|
+
{id}
|
|
230
|
+
</span>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="comment">
|
|
236
|
+
<textarea
|
|
237
|
+
title="Comentário ou instrução adicional para o auto-fix"
|
|
238
|
+
placeholder="Comentário / instrução para o auto-fix — salvo automaticamente"
|
|
239
|
+
value={comment}
|
|
240
|
+
onInput={(event) =>
|
|
241
|
+
setComment((event.target as HTMLTextAreaElement).value)
|
|
242
|
+
}
|
|
243
|
+
/>
|
|
244
|
+
{saveState === "saving" && (
|
|
245
|
+
<span class="comment-status">
|
|
246
|
+
<Spinner size={12} class="icon spin" /> Salvando…
|
|
247
|
+
</span>
|
|
248
|
+
)}
|
|
249
|
+
{saveState === "saved" && (
|
|
250
|
+
<span class="comment-status saved">
|
|
251
|
+
<Check size={12} /> Salvo
|
|
252
|
+
</span>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
118
255
|
</div>
|
|
119
|
-
|
|
256
|
+
)}
|
|
120
257
|
</article>
|
|
121
258
|
);
|
|
122
259
|
}
|
|
260
|
+
|
|
261
|
+
function truncateText(text: string, maxLength: number): string {
|
|
262
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
263
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lista de findings agrupada por severidade (
|
|
2
|
+
* Lista de findings agrupada por severidade (blocking > attention > suggestion > nit).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useMemo } from "preact/hooks";
|
|
6
|
-
import {
|
|
6
|
+
import { SEVERITY_LABELS, SEVERITY_ORDER } from "./constants";
|
|
7
7
|
import { FindingCard } from "./FindingCard";
|
|
8
|
+
import { Alert } from "./icons";
|
|
8
9
|
import type { Finding, Severity } from "./types";
|
|
9
10
|
|
|
10
11
|
export function FindingsList({
|
|
@@ -33,10 +34,11 @@ export function FindingsList({
|
|
|
33
34
|
const items = grouped.get(severity) ?? [];
|
|
34
35
|
if (items.length === 0) return null;
|
|
35
36
|
return (
|
|
36
|
-
<section class=
|
|
37
|
-
<h2>
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
<section class={`sev-group sev-${severity}`} key={severity}>
|
|
38
|
+
<h2 class="sev-head">
|
|
39
|
+
<Alert size={14} />
|
|
40
|
+
{SEVERITY_LABELS[severity]}
|
|
41
|
+
<span class="n">{items.length}</span>
|
|
40
42
|
</h2>
|
|
41
43
|
{items.map((finding) => (
|
|
42
44
|
<FindingCard
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar: logo do houndsman + lista de
|
|
2
|
+
* Sidebar: logo do houndsman + lista de execuções (mais recentes primeiro),
|
|
3
|
+
* com data/hora, relação de findings presentes e badge de correção pronta.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import { describeRunStatus } from "./constants";
|
|
7
|
+
import { formatRunTime } from "./format";
|
|
8
|
+
import { BranchIcon, Clock, Sparkles } from "./icons";
|
|
6
9
|
import type { RunSummary } from "./types";
|
|
7
10
|
|
|
8
11
|
export function RunList({
|
|
@@ -17,16 +20,18 @@ export function RunList({
|
|
|
17
20
|
return (
|
|
18
21
|
<aside class="sidebar">
|
|
19
22
|
<div class="brand">
|
|
20
|
-
<img class="logo" src="/assets/logo.png" alt="
|
|
23
|
+
<img class="logo" src="/assets/logo.png" alt="" />
|
|
21
24
|
<div>
|
|
22
25
|
<h1>Houndsman</h1>
|
|
23
26
|
<p class="sub">Revisões e triagem</p>
|
|
24
27
|
</div>
|
|
25
28
|
</div>
|
|
29
|
+
<p class="section-label">Execuções</p>
|
|
26
30
|
<nav class="runs">
|
|
27
31
|
{runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
|
|
28
32
|
{runs.map((run) => {
|
|
29
33
|
const status = describeRunStatus(run);
|
|
34
|
+
const fixReady = status?.kind === "fix_done";
|
|
30
35
|
return (
|
|
31
36
|
<button
|
|
32
37
|
type="button"
|
|
@@ -34,14 +39,32 @@ export function RunList({
|
|
|
34
39
|
class={`run${run.slug === slug ? " active" : ""}`}
|
|
35
40
|
onClick={() => onSelect(run.slug)}
|
|
36
41
|
>
|
|
37
|
-
<span class="run-
|
|
42
|
+
<span class="run-top">
|
|
43
|
+
<BranchIcon size={14} class="run-icon" />
|
|
44
|
+
<span class="run-branch">{run.branch}</span>
|
|
45
|
+
</span>
|
|
38
46
|
<span class="run-meta">
|
|
39
|
-
|
|
47
|
+
<span class="run-when">
|
|
48
|
+
<Clock size={12} />
|
|
49
|
+
{formatRunTime(run.updatedAt) || "sem data"}
|
|
50
|
+
</span>
|
|
51
|
+
<span
|
|
52
|
+
class="run-count"
|
|
53
|
+
title={`${run.summary.present} de ${run.summary.total} findings presentes`}
|
|
54
|
+
>
|
|
55
|
+
{run.summary.present}/{run.summary.total}
|
|
56
|
+
</span>
|
|
40
57
|
</span>
|
|
41
|
-
{
|
|
42
|
-
<span class=
|
|
43
|
-
{
|
|
58
|
+
{fixReady ? (
|
|
59
|
+
<span class="run-badge">
|
|
60
|
+
<Sparkles size={12} /> Correção pronta
|
|
44
61
|
</span>
|
|
62
|
+
) : (
|
|
63
|
+
status && (
|
|
64
|
+
<span class={`run-status status-${status.kind}`}>
|
|
65
|
+
{status.label}
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
45
68
|
)}
|
|
46
69
|
</button>
|
|
47
70
|
);
|