@payconductor/houndsman 0.1.3 → 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.
@@ -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
- * Barra de resumo da triagem (total, presentes e contagens por decisão/execução).
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
- export function SummaryBar({ summary }: { summary: Summary }) {
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="summary-bar">
11
- <span>Total: {summary.total}</span>
12
- <span>Presentes: {summary.present}</span>
13
- {DECISIONS.filter((decision) => decision !== "pending").map((decision) => (
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
- export const SEVERITY_EMOJI: Record<Severity, string> = {
8
- blocking: "❌",
9
- attention: "⚠️",
10
- suggestion: "💡",
11
- nit: "🔎",
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
+ }