@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.
@@ -1,12 +1,31 @@
1
1
  /**
2
- * Cartão de um finding: dados + decisão (Executar/Ignorar), comentário e "feito".
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, EXEC_LABELS, SEVERITY_EMOJI } from "./constants";
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 [comment, setComment] = useState(finding.comment ?? "");
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
- setComment(finding.comment ?? "");
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 count = finding.count > 1 ? `×${finding.count}` : "";
48
- const isDone = finding.execState === "done";
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
- <header class="card-head">
55
- <span class="sev">
56
- {SEVERITY_EMOJI[finding.severity]} {finding.severity}
57
- </span>
58
- <code class="loc">{location}</code>
59
- {count && <span class="count">{count}</span>}
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
- <code class="id" title="id do finding">
62
- {finding.id}
63
- </code>
64
- </header>
65
- <h3 class="rule">{finding.rule}</h3>
66
- <p class="msg">{finding.message}</p>
67
- <p class="action">
68
- <strong>Ação:</strong> {finding.action}
69
- </p>
70
- {finding.evidence && <pre class="evidence">{finding.evidence}</pre>}
71
- <footer class="card-foot">
72
- <span class="watchdogs">{finding.watchdogIds.join(", ") || "—"}</span>
73
- <span class="exec-state">{EXEC_LABELS[finding.execState]}</span>
74
- </footer>
75
- <div class="decisions">
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
- <div class="comment-row">
91
- <textarea
92
- title="Comentário ou instrução adicional para o auto-fix"
93
- placeholder="Comentário / instrução para o auto-fix (opcional)"
94
- value={comment}
95
- onInput={(event) =>
96
- setComment((event.target as HTMLTextAreaElement).value)
97
- }
98
- />
99
- <div class="comment-actions">
100
- <button
101
- type="button"
102
- title="Salvar o comentário neste finding"
103
- class="btn"
104
- disabled={busy}
105
- onClick={() => patch({ comment: comment.trim() })}
106
- >
107
- Salvar comentário
108
- </button>
109
- <button
110
- type="button"
111
- title="Alternar o estado de execução deste finding (feito/pendente)"
112
- class={`btn done${isDone ? " active" : ""}`}
113
- disabled={busy}
114
- onClick={() => patch({ execState: isDone ? "pending" : "done" })}
115
- >
116
- {isDone ? "✓ Feito" : "Marcar feito"}
117
- </button>
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
- </div>
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 { SEVERITY_EMOJI, SEVERITY_ORDER } from "./constants";
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="sev-group">
37
- <h2>
38
- {SEVERITY_EMOJI[severity]} {severity}{" "}
39
- <span class="n">({items.length})</span>
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 runs (branch e nº de findings).
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="houndsman" />
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-branch">{run.branch}</span>
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
- {run.summary.present}/{run.summary.total} presentes
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
- {status && (
42
- <span class={`run-status status-${status.kind}`}>
43
- {status.label}
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
  );