@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
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal "Enviar revisão": publica os findings selecionados como comentários na
|
|
3
|
+
* Pull Request (`POST /submit`, via `gh`).
|
|
4
|
+
*
|
|
5
|
+
* Filtra por severidade e estado de execução, mostra uma prévia curta do que
|
|
6
|
+
* será disparado e o resultado (com o link do comentário geral, quando houver).
|
|
7
|
+
* Fail-open: sem `gh`/PR o servidor devolve `available:false` e o modal só
|
|
8
|
+
* informa — nada é postado.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
|
12
|
+
import { api } from "./api";
|
|
13
|
+
import { EXEC_STATES, SEVERITY_LABELS, SEVERITY_ORDER } from "./constants";
|
|
14
|
+
import { ExternalLink, Send, Spinner } from "./icons";
|
|
15
|
+
import type {
|
|
16
|
+
ExecState,
|
|
17
|
+
Severity,
|
|
18
|
+
SubmitOutcome,
|
|
19
|
+
SubmitPreview,
|
|
20
|
+
SubmitResponse,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
const EXEC_LABELS: Record<ExecState, string> = {
|
|
24
|
+
pending: "Pendente",
|
|
25
|
+
in_progress: "Em andamento",
|
|
26
|
+
done: "Feito",
|
|
27
|
+
stale: "Stale",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function SubmitModal({
|
|
31
|
+
slug,
|
|
32
|
+
branch,
|
|
33
|
+
open,
|
|
34
|
+
onClose,
|
|
35
|
+
onSubmitted,
|
|
36
|
+
}: {
|
|
37
|
+
slug: string;
|
|
38
|
+
branch: string;
|
|
39
|
+
open: boolean;
|
|
40
|
+
onClose: () => void;
|
|
41
|
+
onSubmitted: () => void;
|
|
42
|
+
}) {
|
|
43
|
+
const [preview, setPreview] = useState<SubmitPreview | null>(null);
|
|
44
|
+
const [severities, setSeverities] = useState<Severity[]>([]);
|
|
45
|
+
const [execStates, setExecStates] = useState<ExecState[]>([]);
|
|
46
|
+
const [includeSent, setIncludeSent] = useState(false);
|
|
47
|
+
const [loading, setLoading] = useState(false);
|
|
48
|
+
const [posting, setPosting] = useState(false);
|
|
49
|
+
const [error, setError] = useState("");
|
|
50
|
+
const [result, setResult] = useState<SubmitOutcome | null>(null);
|
|
51
|
+
|
|
52
|
+
// Carrega a prévia ao abrir (os filtros são aplicados no cliente).
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!open) return;
|
|
55
|
+
let alive = true;
|
|
56
|
+
setLoading(true);
|
|
57
|
+
setError("");
|
|
58
|
+
setResult(null);
|
|
59
|
+
api<SubmitPreview>(`/api/runs/${slug}/submit-preview`)
|
|
60
|
+
.then((data) => {
|
|
61
|
+
if (alive) setPreview(data);
|
|
62
|
+
})
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
if (alive) setError(String(err));
|
|
65
|
+
})
|
|
66
|
+
.finally(() => {
|
|
67
|
+
if (alive) setLoading(false);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
alive = false;
|
|
71
|
+
};
|
|
72
|
+
}, [open, slug]);
|
|
73
|
+
|
|
74
|
+
// Esc fecha o modal.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!open) return;
|
|
77
|
+
const onKey = (event: KeyboardEvent) => {
|
|
78
|
+
if (event.key === "Escape") onClose();
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener("keydown", onKey);
|
|
81
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
82
|
+
}, [open, onClose]);
|
|
83
|
+
|
|
84
|
+
// Limpa o estado ao fechar: reabrir começa do zero (sem prévia/resultado antigos).
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (open) return;
|
|
87
|
+
setPreview(null);
|
|
88
|
+
setSeverities([]);
|
|
89
|
+
setExecStates([]);
|
|
90
|
+
setIncludeSent(false);
|
|
91
|
+
setLoading(false);
|
|
92
|
+
setPosting(false);
|
|
93
|
+
setError("");
|
|
94
|
+
setResult(null);
|
|
95
|
+
}, [open]);
|
|
96
|
+
|
|
97
|
+
const toggleSeverity = (value: Severity) =>
|
|
98
|
+
setSeverities((current) =>
|
|
99
|
+
current.includes(value)
|
|
100
|
+
? current.filter((item) => item !== value)
|
|
101
|
+
: [...current, value],
|
|
102
|
+
);
|
|
103
|
+
const toggleExec = (value: ExecState) =>
|
|
104
|
+
setExecStates((current) =>
|
|
105
|
+
current.includes(value)
|
|
106
|
+
? current.filter((item) => item !== value)
|
|
107
|
+
: [...current, value],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const selected = useMemo(
|
|
111
|
+
() =>
|
|
112
|
+
(preview?.candidates ?? []).filter(
|
|
113
|
+
(candidate) =>
|
|
114
|
+
(severities.length === 0 ||
|
|
115
|
+
severities.includes(candidate.severity)) &&
|
|
116
|
+
(execStates.length === 0 || execStates.includes(candidate.execState)),
|
|
117
|
+
),
|
|
118
|
+
[preview, severities, execStates],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const toSend = useMemo(
|
|
122
|
+
() => selected.filter((candidate) => includeSent || !candidate.sent),
|
|
123
|
+
[selected, includeSent],
|
|
124
|
+
);
|
|
125
|
+
const onLine = toSend.filter((candidate) => candidate.kind === "line").length;
|
|
126
|
+
const onFile = toSend.filter((candidate) => candidate.kind === "file").length;
|
|
127
|
+
const skipped = selected.length - toSend.length;
|
|
128
|
+
|
|
129
|
+
const submit = useCallback(async () => {
|
|
130
|
+
setPosting(true);
|
|
131
|
+
setError("");
|
|
132
|
+
try {
|
|
133
|
+
const res = await api<SubmitResponse>(`/api/runs/${slug}/submit`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "content-type": "application/json" },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
severities: severities.length > 0 ? severities : undefined,
|
|
138
|
+
execStates: execStates.length > 0 ? execStates : undefined,
|
|
139
|
+
includeSent,
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
if (!res.available) {
|
|
143
|
+
setError(res.reason ?? "PR indisponível para envio.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
setResult(res.outcome ?? null);
|
|
147
|
+
onSubmitted();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
setError(String(err));
|
|
150
|
+
} finally {
|
|
151
|
+
setPosting(false);
|
|
152
|
+
}
|
|
153
|
+
}, [slug, severities, execStates, includeSent, onSubmitted]);
|
|
154
|
+
|
|
155
|
+
if (!open) return null;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div class="modal-layer">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
class="modal-backdrop"
|
|
162
|
+
aria-label="Fechar"
|
|
163
|
+
title="Fechar"
|
|
164
|
+
onClick={onClose}
|
|
165
|
+
/>
|
|
166
|
+
<div
|
|
167
|
+
class="modal"
|
|
168
|
+
role="dialog"
|
|
169
|
+
aria-modal="true"
|
|
170
|
+
aria-label="Enviar revisão"
|
|
171
|
+
>
|
|
172
|
+
<header class="modal-head">
|
|
173
|
+
<div>
|
|
174
|
+
<h2 class="modal-title">Enviar revisão</h2>
|
|
175
|
+
<p class="modal-sub">
|
|
176
|
+
Publica os findings selecionados como comentários na PR
|
|
177
|
+
{branch ? ` de ${branch}` : ""} (via gh).
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class="modal-close"
|
|
183
|
+
title="Fechar"
|
|
184
|
+
onClick={onClose}
|
|
185
|
+
>
|
|
186
|
+
×
|
|
187
|
+
</button>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
<div class="modal-body">
|
|
191
|
+
{loading && (
|
|
192
|
+
<p class="submit-empty">
|
|
193
|
+
<Spinner size={14} class="icon spin" /> Carregando prévia…
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
{error && <p class="error">{error}</p>}
|
|
197
|
+
|
|
198
|
+
{result ? (
|
|
199
|
+
<ResultView outcome={result} />
|
|
200
|
+
) : (
|
|
201
|
+
preview && (
|
|
202
|
+
<>
|
|
203
|
+
{!preview.available && (
|
|
204
|
+
<p class="submit-empty">
|
|
205
|
+
PR indisponível: {preview.reason}. A triagem
|
|
206
|
+
segue; o envio fica para quando houver PR + gh.
|
|
207
|
+
</p>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
<div class="submit-field">
|
|
211
|
+
<span class="submit-label">Severidade</span>
|
|
212
|
+
<div class="chips">
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
class={`chip${severities.length === 0 ? " active" : ""}`}
|
|
216
|
+
onClick={() => setSeverities([])}
|
|
217
|
+
>
|
|
218
|
+
Todas
|
|
219
|
+
</button>
|
|
220
|
+
{SEVERITY_ORDER.map((severity) => (
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
key={severity}
|
|
224
|
+
class={`chip sev-${severity}${
|
|
225
|
+
severities.includes(severity)
|
|
226
|
+
? " active"
|
|
227
|
+
: ""
|
|
228
|
+
}`}
|
|
229
|
+
onClick={() => toggleSeverity(severity)}
|
|
230
|
+
>
|
|
231
|
+
{SEVERITY_LABELS[severity]}
|
|
232
|
+
</button>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div class="submit-field">
|
|
238
|
+
<span class="submit-label">Execução</span>
|
|
239
|
+
<div class="chips">
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
class={`chip${execStates.length === 0 ? " active" : ""}`}
|
|
243
|
+
onClick={() => setExecStates([])}
|
|
244
|
+
>
|
|
245
|
+
Todos
|
|
246
|
+
</button>
|
|
247
|
+
{EXEC_STATES.map((state) => (
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
key={state}
|
|
251
|
+
class={`chip${
|
|
252
|
+
execStates.includes(state)
|
|
253
|
+
? " active"
|
|
254
|
+
: ""
|
|
255
|
+
}`}
|
|
256
|
+
onClick={() => toggleExec(state)}
|
|
257
|
+
>
|
|
258
|
+
{EXEC_LABELS[state]}
|
|
259
|
+
</button>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<label class="submit-check">
|
|
265
|
+
<input
|
|
266
|
+
type="checkbox"
|
|
267
|
+
checked={includeSent}
|
|
268
|
+
onChange={(event) =>
|
|
269
|
+
setIncludeSent(
|
|
270
|
+
(event.target as HTMLInputElement)
|
|
271
|
+
.checked,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
Reenviar os já enviados
|
|
276
|
+
</label>
|
|
277
|
+
|
|
278
|
+
<div class="submit-summary">
|
|
279
|
+
Serão disparados <b>{toSend.length}</b> comentário(s)
|
|
280
|
+
— <b>{onLine}</b> na linha · <b>{onFile}</b> no
|
|
281
|
+
arquivo
|
|
282
|
+
{skipped > 0 ? (
|
|
283
|
+
<>
|
|
284
|
+
{" "}
|
|
285
|
+
· <b>{skipped}</b> já enviado(s) pulado(s)
|
|
286
|
+
</>
|
|
287
|
+
) : null}
|
|
288
|
+
.
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div class="submit-field">
|
|
292
|
+
<span class="submit-label">
|
|
293
|
+
Prévia ({toSend.length})
|
|
294
|
+
</span>
|
|
295
|
+
{toSend.length === 0 ? (
|
|
296
|
+
<p class="submit-empty">
|
|
297
|
+
Nada a enviar com os filtros atuais.
|
|
298
|
+
</p>
|
|
299
|
+
) : (
|
|
300
|
+
<div class="submit-list">
|
|
301
|
+
{toSend.map((candidate) => (
|
|
302
|
+
<div
|
|
303
|
+
class={`submit-item${candidate.sent ? " sent" : ""}`}
|
|
304
|
+
key={candidate.id}
|
|
305
|
+
>
|
|
306
|
+
<span
|
|
307
|
+
class={`sev-badge sev-${candidate.severity}`}
|
|
308
|
+
>
|
|
309
|
+
{
|
|
310
|
+
SEVERITY_LABELS[
|
|
311
|
+
candidate.severity
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
</span>
|
|
315
|
+
<code class="loc static">
|
|
316
|
+
{candidate.line
|
|
317
|
+
? `${candidate.file}:${candidate.line}`
|
|
318
|
+
: candidate.file}
|
|
319
|
+
</code>
|
|
320
|
+
<span class="rule">
|
|
321
|
+
{candidate.rule}
|
|
322
|
+
</span>
|
|
323
|
+
{candidate.kind === "file" && (
|
|
324
|
+
<span
|
|
325
|
+
class="tag"
|
|
326
|
+
title="Ancorado no arquivo (sem linha específica)"
|
|
327
|
+
>
|
|
328
|
+
arquivo
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</>
|
|
337
|
+
)
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<footer class="modal-foot">
|
|
342
|
+
<span class="submit-label">
|
|
343
|
+
{preview?.available && preview.target
|
|
344
|
+
? `PR #${preview.target.number} · ${preview.target.repo}`
|
|
345
|
+
: "sem PR"}
|
|
346
|
+
</span>
|
|
347
|
+
{result ? (
|
|
348
|
+
<button type="button" class="btn" onClick={onClose}>
|
|
349
|
+
Fechar
|
|
350
|
+
</button>
|
|
351
|
+
) : (
|
|
352
|
+
<div class="submit-actions">
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
class="btn"
|
|
356
|
+
disabled={posting}
|
|
357
|
+
onClick={onClose}
|
|
358
|
+
>
|
|
359
|
+
Cancelar
|
|
360
|
+
</button>
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
class="btn submit-review"
|
|
364
|
+
disabled={
|
|
365
|
+
posting || !preview?.available || toSend.length === 0
|
|
366
|
+
}
|
|
367
|
+
onClick={() => void submit()}
|
|
368
|
+
>
|
|
369
|
+
{posting ? (
|
|
370
|
+
<Spinner size={15} class="icon spin" />
|
|
371
|
+
) : (
|
|
372
|
+
<Send size={15} />
|
|
373
|
+
)}
|
|
374
|
+
{posting ? "Enviando…" : `Enviar (${toSend.length})`}
|
|
375
|
+
</button>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</footer>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Resumo do resultado do envio. */
|
|
385
|
+
function ResultView({ outcome }: { outcome: SubmitOutcome }) {
|
|
386
|
+
const generalUrl = outcome.general?.url;
|
|
387
|
+
return (
|
|
388
|
+
<>
|
|
389
|
+
<div class="submit-summary">
|
|
390
|
+
Enviados <b>{outcome.posted.length}</b> · falhas{" "}
|
|
391
|
+
<b>{outcome.failed.length}</b> · pulados <b>{outcome.skipped.length}</b>.
|
|
392
|
+
</div>
|
|
393
|
+
{generalUrl && (
|
|
394
|
+
<p class="submit-empty">
|
|
395
|
+
Comentário geral:{" "}
|
|
396
|
+
<a
|
|
397
|
+
class="sent-tag"
|
|
398
|
+
href={generalUrl}
|
|
399
|
+
target="_blank"
|
|
400
|
+
rel="noreferrer"
|
|
401
|
+
>
|
|
402
|
+
<ExternalLink size={12} /> abrir
|
|
403
|
+
</a>
|
|
404
|
+
</p>
|
|
405
|
+
)}
|
|
406
|
+
{outcome.failed.length > 0 && (
|
|
407
|
+
<div class="submit-list">
|
|
408
|
+
{outcome.failed.map((failure) => (
|
|
409
|
+
<div class="submit-item" key={failure.id}>
|
|
410
|
+
<code class="loc static">{failure.id}</code>
|
|
411
|
+
<span class="rule">{failure.reason}</span>
|
|
412
|
+
</div>
|
|
413
|
+
))}
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
</>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
@@ -1,21 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Métricas da triagem: cards de KPI (Total/Executar/Ignorar/Feitos) e a barra de
|
|
3
|
+
* progresso da correção (feitos ÷ total).
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
import { DECISION_LABELS, DECISIONS } from "./constants";
|
|
6
6
|
import type { Summary } from "./types";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function Kpi({
|
|
9
|
+
label,
|
|
10
|
+
value,
|
|
11
|
+
tone,
|
|
12
|
+
hint,
|
|
13
|
+
}: {
|
|
14
|
+
label: string;
|
|
15
|
+
value: number;
|
|
16
|
+
tone: "total" | "execute" | "ignore" | "done";
|
|
17
|
+
hint?: string;
|
|
18
|
+
}) {
|
|
9
19
|
return (
|
|
10
|
-
<div class=
|
|
11
|
-
<span>
|
|
12
|
-
<span>
|
|
13
|
-
{
|
|
14
|
-
<span key={decision}>
|
|
15
|
-
{DECISION_LABELS[decision]}: {summary.byDecision[decision] ?? 0}
|
|
16
|
-
</span>
|
|
17
|
-
))}
|
|
18
|
-
<span>Feitos: {summary.byExecState.done ?? 0}</span>
|
|
20
|
+
<div class={`kpi kpi-${tone}`} title={hint}>
|
|
21
|
+
<span class="kpi-label">{label}</span>
|
|
22
|
+
<span class="kpi-value">{value}</span>
|
|
23
|
+
{hint && <span class="kpi-hint">{hint}</span>}
|
|
19
24
|
</div>
|
|
20
25
|
);
|
|
21
26
|
}
|
|
27
|
+
|
|
28
|
+
export function SummaryBar({ summary }: { summary: Summary }) {
|
|
29
|
+
const done = summary.byExecState.done ?? 0;
|
|
30
|
+
const total = summary.total - (summary.byDecision.ignore || 0);
|
|
31
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<div class="kpis">
|
|
36
|
+
<Kpi
|
|
37
|
+
label="Total"
|
|
38
|
+
value={summary.total}
|
|
39
|
+
tone="total"
|
|
40
|
+
hint={`${summary.present} presentes`}
|
|
41
|
+
/>
|
|
42
|
+
<Kpi
|
|
43
|
+
label="Executar"
|
|
44
|
+
value={summary.byDecision.execute ?? 0}
|
|
45
|
+
tone="execute"
|
|
46
|
+
hint="marcados para auto-fix"
|
|
47
|
+
/>
|
|
48
|
+
<Kpi
|
|
49
|
+
label="Ignorar"
|
|
50
|
+
value={summary.byDecision.ignore ?? 0}
|
|
51
|
+
tone="ignore"
|
|
52
|
+
hint="descartados na triagem"
|
|
53
|
+
/>
|
|
54
|
+
<Kpi label="Feitos" value={done} tone="done" hint="corrigidos" />
|
|
55
|
+
</div>
|
|
56
|
+
<div class="progress">
|
|
57
|
+
<div class="progress-head">
|
|
58
|
+
<span>Progresso da correção</span>
|
|
59
|
+
<span class="pct">{pct}%</span>
|
|
60
|
+
</div>
|
|
61
|
+
<div
|
|
62
|
+
class="progress-track"
|
|
63
|
+
role="progressbar"
|
|
64
|
+
aria-valuemin={0}
|
|
65
|
+
aria-valuemax={100}
|
|
66
|
+
aria-valuenow={pct}
|
|
67
|
+
title={`${done} de ${summary.total} findings feitos`}
|
|
68
|
+
>
|
|
69
|
+
<span class="progress-fill" style={{ width: `${pct}%` }} />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Decision, ExecState, RunStatus, RunSummary, Severity } from "./types";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
/** Rótulo de exibição de cada severidade (o CSS dá a cor via `sev-<nivel>`). */
|
|
8
|
+
export const SEVERITY_LABELS: Record<Severity, string> = {
|
|
9
|
+
blocking: "Blocking",
|
|
10
|
+
attention: "Attention",
|
|
11
|
+
suggestion: "Suggestion",
|
|
12
|
+
nit: "Nit",
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export const SEVERITY_ORDER: Severity[] = ["blocking", "attention", "suggestion", "nit"];
|
|
@@ -23,13 +24,6 @@ export const DECISION_LABELS: Record<Decision, string> = {
|
|
|
23
24
|
|
|
24
25
|
export const EXEC_STATES: ExecState[] = ["pending", "in_progress", "done", "stale"];
|
|
25
26
|
|
|
26
|
-
export const EXEC_LABELS: Record<ExecState, string> = {
|
|
27
|
-
pending: "exec: pendente",
|
|
28
|
-
in_progress: "exec: em andamento",
|
|
29
|
-
done: "exec: feito",
|
|
30
|
-
stale: "exec: stale",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
27
|
export const RUN_STATUS_LABELS: Record<RunStatus, string> = {
|
|
34
28
|
reviewing: "Revisando",
|
|
35
29
|
review_done: "Revisão pronta",
|
|
@@ -40,6 +34,9 @@ export const RUN_STATUS_LABELS: Record<RunStatus, string> = {
|
|
|
40
34
|
/** Limiar de inatividade para exibir timeout (display-only; não é status). */
|
|
41
35
|
export const STATUS_TIMEOUT_MS = 10 * 60 * 1000;
|
|
42
36
|
|
|
37
|
+
/** Intervalo do polling da UI (o badge conta o tempo desde a última sync). */
|
|
38
|
+
export const AUTO_REFRESH_MS = 10_000;
|
|
39
|
+
|
|
43
40
|
export type RunStatusView = { kind: string; label: string; timeout: boolean };
|
|
44
41
|
|
|
45
42
|
/** View do status de uma run — label; vira timeout quando ativo e sem ping recente. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers de apresentação: data/hora das runs e link para abrir o arquivo no
|
|
3
|
+
* VS Code (`vscode://file/...`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Data/hora curta em pt-BR — ex.: `14 de out., 17:48`. */
|
|
7
|
+
export function formatRunTime(iso?: string): string {
|
|
8
|
+
if (!iso) return "";
|
|
9
|
+
const date = new Date(iso);
|
|
10
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
11
|
+
return new Intl.DateTimeFormat("pt-BR", {
|
|
12
|
+
day: "2-digit",
|
|
13
|
+
month: "short",
|
|
14
|
+
hour: "2-digit",
|
|
15
|
+
minute: "2-digit",
|
|
16
|
+
}).format(date);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Tempo relativo curto — ex.: `há 8s`, `há 3min`. */
|
|
20
|
+
export function formatElapsed(seconds: number): string {
|
|
21
|
+
if (seconds < 2) return "agora";
|
|
22
|
+
if (seconds < 60) return `há ${seconds}s`;
|
|
23
|
+
const minutes = Math.floor(seconds / 60);
|
|
24
|
+
if (minutes < 60) return `há ${minutes}min`;
|
|
25
|
+
return `há ${Math.floor(minutes / 60)}h`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Link `vscode://file/<caminho>:<linha>` para abrir o arquivo no editor.
|
|
30
|
+
* Devolve null sem caminho absoluto (o handler do VS Code não resolve relativo).
|
|
31
|
+
*/
|
|
32
|
+
export function vscodeFileLink(file: string, line?: number | null): string | null {
|
|
33
|
+
const path = file.replace(/\\/g, "/");
|
|
34
|
+
if (!/^([a-zA-Z]:\/|\/)/.test(path)) return null;
|
|
35
|
+
const normalized = path.replace(
|
|
36
|
+
/^([A-Za-z]):/,
|
|
37
|
+
(_, drive) => `${drive.toLowerCase()}:`,
|
|
38
|
+
);
|
|
39
|
+
return `vscode://file/${encodeURI(normalized)}${line ? `:${line}` : ""}`;
|
|
40
|
+
}
|