@payconductor/houndsman 0.6.0 → 0.7.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,397 @@
1
+ /**
2
+ * Sidebar: logo do houndsman + abas `Execuções` e `PR's`.
3
+ *
4
+ * A aba de execuções mantém a lista de runs (status, data e findings). A aba de
5
+ * PRs lista as PRs abertas via `gh`, permite abrir a run correspondente e faz
6
+ * checkout somente quando a árvore de trabalho está limpa.
7
+ */
8
+
9
+ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
10
+ import {
11
+ BranchIcon,
12
+ Clock,
13
+ GitHub,
14
+ GitPullRequest,
15
+ ListIcon,
16
+ Refresh,
17
+ Sparkles,
18
+ Spinner,
19
+ } from "./icons";
20
+ import { api } from "./utils/api";
21
+ import { describeRunStatus } from "./utils/constants";
22
+ import { formatRunTime } from "./utils/format";
23
+ import type {
24
+ CheckoutResponse,
25
+ PrListItem,
26
+ PrListResponse,
27
+ RunSummary,
28
+ WorktreeState,
29
+ } from "./utils/types";
30
+
31
+ type SidebarView = "runs" | "prs";
32
+
33
+ const REVIEW_LABELS: Record<string, string> = {
34
+ APPROVED: "aprovada",
35
+ CHANGES_REQUESTED: "mudanças",
36
+ REVIEW_REQUIRED: "revisão pendente",
37
+ };
38
+
39
+ function RunItem({
40
+ run,
41
+ active,
42
+ onSelect,
43
+ }: {
44
+ run: RunSummary;
45
+ active: boolean;
46
+ onSelect: (slug: string) => void;
47
+ }) {
48
+ const status = describeRunStatus(run);
49
+ const fixReady = status?.kind === "fix_done";
50
+ return (
51
+ <button
52
+ type="button"
53
+ title={`Abrir a run da branch ${run.branch}`}
54
+ class={`run${active ? " active" : ""}`}
55
+ onClick={() => onSelect(run.slug)}
56
+ >
57
+ <span class="run-top">
58
+ <BranchIcon size={14} class="run-icon" />
59
+ <span class="run-branch">{run.branch}</span>
60
+ </span>
61
+ <span class="run-meta">
62
+ <span class="run-when">
63
+ <Clock size={12} />
64
+ {formatRunTime(run.updatedAt) || "sem data"}
65
+ </span>
66
+ <span
67
+ class="run-count"
68
+ title={`${run.summary.present} de ${run.summary.total} findings presentes`}
69
+ >
70
+ {run.summary.present}/{run.summary.total}
71
+ </span>
72
+ </span>
73
+ {fixReady ? (
74
+ <span class="run-badge">
75
+ <Sparkles size={12} /> Correção pronta
76
+ </span>
77
+ ) : (
78
+ status && (
79
+ <span class={`run-status status-${status.kind}`}>{status.label}</span>
80
+ )
81
+ )}
82
+ </button>
83
+ );
84
+ }
85
+
86
+ function PrItem({
87
+ pr,
88
+ currentBranch,
89
+ worktree,
90
+ busy,
91
+ confirming,
92
+ onConfirm,
93
+ onCancel,
94
+ onCheckout,
95
+ onSelectRun,
96
+ }: {
97
+ pr: PrListItem;
98
+ currentBranch: string | null;
99
+ worktree: WorktreeState;
100
+ busy: boolean;
101
+ confirming: boolean;
102
+ onConfirm: (number: number) => void;
103
+ onCancel: () => void;
104
+ onCheckout: (pr: PrListItem) => void;
105
+ onSelectRun: (slug: string) => void;
106
+ }) {
107
+ const isCurrent = currentBranch === pr.headRefName;
108
+ const runSlug = pr.runSlug;
109
+ const review = pr.reviewDecision ? REVIEW_LABELS[pr.reviewDecision] : undefined;
110
+ return (
111
+ <article class={`pr${isCurrent ? " current" : ""}`}>
112
+ <div class="pr-head">
113
+ <GitPullRequest size={14} class="pr-icon" />
114
+ <span class="pr-number">#{pr.number}</span>
115
+ {pr.isDraft && <span class="tag">draft</span>}
116
+ {isCurrent && <span class="pr-current">atual</span>}
117
+ <span class="spacer" />
118
+ {pr.updatedAt && (
119
+ <span class="run-when">
120
+ <Clock size={12} />
121
+ {formatRunTime(pr.updatedAt)}
122
+ </span>
123
+ )}
124
+ </div>
125
+ <p class="pr-title">{pr.title}</p>
126
+ <p class="pr-meta">
127
+ <span class="pr-branch">{pr.headRefName}</span>
128
+ <span class="pr-arrow">→</span>
129
+ <span>{pr.baseRefName || "—"}</span>
130
+ <span>· {pr.author}</span>
131
+ </p>
132
+ {review && <p class={`pr-review pr-review-${pr.reviewDecision}`}>{review}</p>}
133
+ <div class="pr-actions">
134
+ {runSlug && (
135
+ <button
136
+ type="button"
137
+ class="pr-btn"
138
+ title="Abrir a run local desta branch"
139
+ onClick={() => onSelectRun(runSlug)}
140
+ >
141
+ Abrir run
142
+ </button>
143
+ )}
144
+ {pr.url && (
145
+ <a
146
+ class="pr-btn"
147
+ href={pr.url}
148
+ target="_blank"
149
+ rel="noreferrer"
150
+ title="Abrir a PR no GitHub"
151
+ >
152
+ <GitHub size={12} /> GitHub
153
+ </a>
154
+ )}
155
+ {confirming ? (
156
+ <>
157
+ <button
158
+ type="button"
159
+ class="pr-btn danger"
160
+ disabled={busy}
161
+ title="Confirmar checkout da PR"
162
+ onClick={() => onCheckout(pr)}
163
+ >
164
+ {busy ? <Spinner size={12} class="icon spin" /> : "Confirmar"}
165
+ </button>
166
+ <button
167
+ type="button"
168
+ class="pr-btn"
169
+ disabled={busy}
170
+ onClick={onCancel}
171
+ >
172
+ Cancelar
173
+ </button>
174
+ </>
175
+ ) : (
176
+ <button
177
+ type="button"
178
+ class="pr-btn"
179
+ disabled={!worktree.clean || busy}
180
+ title={
181
+ worktree.clean
182
+ ? `Fazer checkout da branch ${pr.headRefName}`
183
+ : "Há alterações não commitadas — faça commit ou stash antes"
184
+ }
185
+ onClick={() => onConfirm(pr.number)}
186
+ >
187
+ Checkout
188
+ </button>
189
+ )}
190
+ </div>
191
+ </article>
192
+ );
193
+ }
194
+
195
+ export function Sidebar({
196
+ runs,
197
+ slug,
198
+ onSelect,
199
+ onRefreshRuns,
200
+ }: {
201
+ runs: RunSummary[];
202
+ slug: string | null;
203
+ onSelect: (slug: string) => void;
204
+ onRefreshRuns: () => void;
205
+ }) {
206
+ const [view, setView] = useState<SidebarView>("runs");
207
+ const [data, setData] = useState<PrListResponse | null>(null);
208
+ const [loading, setLoading] = useState(false);
209
+ const [error, setError] = useState("");
210
+ const [notice, setNotice] = useState("");
211
+ const [busy, setBusy] = useState<number | null>(null);
212
+ const [confirming, setConfirming] = useState<number | null>(null);
213
+ const requested = useRef(false);
214
+
215
+ const loadPrs = useCallback(async () => {
216
+ setLoading(true);
217
+ setError("");
218
+ try {
219
+ setData(await api<PrListResponse>("/api/prs"));
220
+ } catch (err) {
221
+ setError(String(err));
222
+ } finally {
223
+ setLoading(false);
224
+ }
225
+ }, []);
226
+
227
+ useEffect(() => {
228
+ if (view !== "prs" || requested.current) return;
229
+ requested.current = true;
230
+ void loadPrs();
231
+ }, [view, loadPrs]);
232
+
233
+ const checkout = useCallback(
234
+ async (pr: PrListItem) => {
235
+ setBusy(pr.number);
236
+ setError("");
237
+ setNotice("");
238
+ try {
239
+ const result = await api<CheckoutResponse>("/api/prs/checkout", {
240
+ method: "POST",
241
+ headers: { "content-type": "application/json" },
242
+ body: JSON.stringify({ number: pr.number }),
243
+ });
244
+ if (!result.ok) {
245
+ if (result.reason === "dirty") {
246
+ setData((current) =>
247
+ current
248
+ ? {
249
+ ...current,
250
+ worktree: {
251
+ ...current.worktree,
252
+ clean: false,
253
+ changes:
254
+ result.changes?.length ??
255
+ current.worktree.changes,
256
+ sample:
257
+ result.changes ?? current.worktree.sample,
258
+ },
259
+ }
260
+ : current,
261
+ );
262
+ }
263
+ setError(
264
+ result.reason === "dirty"
265
+ ? `Checkout bloqueado: ${result.detail}`
266
+ : result.detail,
267
+ );
268
+ return;
269
+ }
270
+ setConfirming(null);
271
+ setNotice(
272
+ `Checkout de #${pr.number} concluído${
273
+ result.branch ? ` — ${result.branch}` : ""
274
+ }.`,
275
+ );
276
+ await loadPrs();
277
+ onRefreshRuns();
278
+ if (pr.runSlug) onSelect(pr.runSlug);
279
+ } catch (err) {
280
+ setError(String(err));
281
+ } finally {
282
+ setBusy(null);
283
+ }
284
+ },
285
+ [loadPrs, onRefreshRuns, onSelect],
286
+ );
287
+
288
+ return (
289
+ <aside class="sidebar">
290
+ <div class="brand">
291
+ <img class="logo" src="/assets/logo.png" alt="" />
292
+ <div>
293
+ <h1>Houndsman</h1>
294
+ <p class="sub">Revisões e triagem</p>
295
+ </div>
296
+ </div>
297
+
298
+ <div class="side-tabs" role="tablist" aria-label="Navegação lateral">
299
+ <button
300
+ type="button"
301
+ role="tab"
302
+ aria-selected={view === "runs"}
303
+ class={`side-tab${view === "runs" ? " active" : ""}`}
304
+ onClick={() => setView("runs")}
305
+ >
306
+ <ListIcon size={13} /> Execuções
307
+ <span class="side-count">{runs.length}</span>
308
+ </button>
309
+ <button
310
+ type="button"
311
+ role="tab"
312
+ aria-selected={view === "prs"}
313
+ class={`side-tab${view === "prs" ? " active" : ""}`}
314
+ onClick={() => setView("prs")}
315
+ >
316
+ <GitPullRequest size={13} /> PR's
317
+ </button>
318
+ </div>
319
+
320
+ {view === "runs" ? (
321
+ <nav class="runs" aria-label="Execuções">
322
+ {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
323
+ {runs.map((run) => (
324
+ <RunItem
325
+ key={run.slug}
326
+ run={run}
327
+ active={run.slug === slug}
328
+ onSelect={onSelect}
329
+ />
330
+ ))}
331
+ </nav>
332
+ ) : (
333
+ <>
334
+ <div class="pr-toolbar">
335
+ <p class="section-label">PRs abertas</p>
336
+ <button
337
+ type="button"
338
+ class="icon-btn"
339
+ title="Atualizar lista de PRs"
340
+ disabled={loading}
341
+ onClick={() => void loadPrs()}
342
+ >
343
+ <Refresh
344
+ size={13}
345
+ class={loading ? "icon spin" : undefined}
346
+ />
347
+ </button>
348
+ </div>
349
+
350
+ {error && <p class="error">{error}</p>}
351
+ {notice && <p class="pr-notice">{notice}</p>}
352
+ {data && !data.available && (
353
+ <p class="empty">
354
+ GitHub indisponível
355
+ {data.reason ? `: ${data.reason}` : "."}
356
+ </p>
357
+ )}
358
+ {data && !data.worktree.available && (
359
+ <p class="empty">Não foi possível ler o estado do git.</p>
360
+ )}
361
+ {data?.worktree.available && !data.worktree.clean && (
362
+ <p class="pr-warning">
363
+ {data.worktree.changes} alteração(ões) não commitada(s) —
364
+ checkout bloqueado até limpar a árvore.
365
+ </p>
366
+ )}
367
+ {data?.currentBranch && (
368
+ <p class="pr-current-branch">
369
+ Branch atual: <code>{data.currentBranch}</code>
370
+ </p>
371
+ )}
372
+
373
+ <nav class="prs" aria-label="Pull requests abertas">
374
+ {loading && !data && <p class="empty">Carregando PRs…</p>}
375
+ {data?.available && data.prs.length === 0 && (
376
+ <p class="empty">Nenhuma PR aberta.</p>
377
+ )}
378
+ {data?.prs.map((pr) => (
379
+ <PrItem
380
+ key={pr.number}
381
+ pr={pr}
382
+ currentBranch={data.currentBranch}
383
+ worktree={data.worktree}
384
+ busy={busy !== null}
385
+ confirming={confirming === pr.number}
386
+ onConfirm={setConfirming}
387
+ onCancel={() => setConfirming(null)}
388
+ onCheckout={checkout}
389
+ onSelectRun={onSelect}
390
+ />
391
+ ))}
392
+ </nav>
393
+ </>
394
+ )}
395
+ </aside>
396
+ );
397
+ }
@@ -2,8 +2,9 @@
2
2
  * Modal "Enviar revisão": publica os findings selecionados como comentários na
3
3
  * Pull Request (`POST /submit`, via `gh`).
4
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).
5
+ * Filtra por severidade, decisão (a executar) e estado de execução, mostra uma
6
+ * prévia curta do que será disparado e o resultado (com o link do comentário
7
+ * geral, quando houver).
7
8
  * Fail-open: sem `gh`/PR o servidor devolve `available:false` e o modal só
8
9
  * informa — nada é postado.
9
10
  */
@@ -11,8 +12,14 @@
11
12
  import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
12
13
  import { GitHub, Send, Spinner } from "./icons";
13
14
  import { api } from "./utils/api";
14
- import { EXEC_STATES, SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
15
+ import {
16
+ DECISION_LABELS,
17
+ EXEC_STATES,
18
+ SEVERITY_LABELS,
19
+ SEVERITY_ORDER,
20
+ } from "./utils/constants";
15
21
  import type {
22
+ Decision,
16
23
  ExecState,
17
24
  Severity,
18
25
  SubmitOutcome,
@@ -27,6 +34,9 @@ const EXEC_LABELS: Record<ExecState, string> = {
27
34
  stale: "Stale",
28
35
  };
29
36
 
37
+ /** Decisões elegíveis ao envio: `ignore` fica de fora por definição. */
38
+ const SUBMIT_DECISIONS: Decision[] = ["execute", "pending"];
39
+
30
40
  export function SubmitModal({
31
41
  slug,
32
42
  branch,
@@ -42,6 +52,7 @@ export function SubmitModal({
42
52
  }) {
43
53
  const [preview, setPreview] = useState<SubmitPreview | null>(null);
44
54
  const [severities, setSeverities] = useState<Severity[]>([]);
55
+ const [decisions, setDecisions] = useState<Decision[]>([]);
45
56
  const [execStates, setExecStates] = useState<ExecState[]>([]);
46
57
  const [includeSent, setIncludeSent] = useState(false);
47
58
  const [loading, setLoading] = useState(false);
@@ -86,6 +97,7 @@ export function SubmitModal({
86
97
  if (open) return;
87
98
  setPreview(null);
88
99
  setSeverities([]);
100
+ setDecisions([]);
89
101
  setExecStates([]);
90
102
  setIncludeSent(false);
91
103
  setLoading(false);
@@ -106,6 +118,12 @@ export function SubmitModal({
106
118
  ? current.filter((item) => item !== value)
107
119
  : [...current, value],
108
120
  );
121
+ const toggleDecision = (value: Decision) =>
122
+ setDecisions((current) =>
123
+ current.includes(value)
124
+ ? current.filter((item) => item !== value)
125
+ : [...current, value],
126
+ );
109
127
 
110
128
  const selected = useMemo(
111
129
  () =>
@@ -113,9 +131,14 @@ export function SubmitModal({
113
131
  (candidate) =>
114
132
  (severities.length === 0 ||
115
133
  severities.includes(candidate.severity)) &&
134
+ // Servidor antigo ainda em memória pode não mandar `decision`:
135
+ // nesse caso não escondemos o candidato do filtro.
136
+ (decisions.length === 0 ||
137
+ !candidate.decision ||
138
+ decisions.includes(candidate.decision)) &&
116
139
  (execStates.length === 0 || execStates.includes(candidate.execState)),
117
140
  ),
118
- [preview, severities, execStates],
141
+ [preview, severities, decisions, execStates],
119
142
  );
120
143
 
121
144
  const toSend = useMemo(
@@ -140,6 +163,7 @@ export function SubmitModal({
140
163
  headers: { "content-type": "application/json" },
141
164
  body: JSON.stringify({
142
165
  severities: severities.length > 0 ? severities : undefined,
166
+ decisions: decisions.length > 0 ? decisions : undefined,
143
167
  execStates: execStates.length > 0 ? execStates : undefined,
144
168
  includeSent,
145
169
  }),
@@ -155,7 +179,7 @@ export function SubmitModal({
155
179
  } finally {
156
180
  setPosting(false);
157
181
  }
158
- }, [slug, severities, execStates, includeSent, onSubmitted]);
182
+ }, [slug, severities, decisions, execStates, includeSent, onSubmitted]);
159
183
 
160
184
  if (!open) return null;
161
185
 
@@ -239,6 +263,38 @@ export function SubmitModal({
239
263
  </div>
240
264
  </div>
241
265
 
266
+ <div class="submit-field">
267
+ <span class="submit-label">Decisão</span>
268
+ <div class="chips">
269
+ <button
270
+ type="button"
271
+ class={`chip${decisions.length === 0 ? " active" : ""}`}
272
+ onClick={() => setDecisions([])}
273
+ >
274
+ Todas
275
+ </button>
276
+ {SUBMIT_DECISIONS.map((decision) => (
277
+ <button
278
+ type="button"
279
+ key={decision}
280
+ title={
281
+ decision === "execute"
282
+ ? "Findings marcados como Executar (a executar)"
283
+ : "Findings ainda não triados"
284
+ }
285
+ class={`chip dec-${decision}${
286
+ decisions.includes(decision)
287
+ ? " active"
288
+ : ""
289
+ }`}
290
+ onClick={() => toggleDecision(decision)}
291
+ >
292
+ {DECISION_LABELS[decision]}
293
+ </button>
294
+ ))}
295
+ </div>
296
+ </div>
297
+
242
298
  <div class="submit-field">
243
299
  <span class="submit-label">Execução</span>
244
300
  <div class="chips">
@@ -158,3 +158,22 @@ export const Send = (p: IconProps) => (
158
158
  <path d="M22 2 15 22l-4-9-9-4z" />
159
159
  </Svg>
160
160
  );
161
+
162
+ /** Pull request — aba de PRs da sidebar. */
163
+ export const GitPullRequest = (p: IconProps) => (
164
+ <Svg {...p}>
165
+ <circle cx="6" cy="6" r="2.4" />
166
+ <circle cx="6" cy="18" r="2.4" />
167
+ <circle cx="18" cy="18" r="2.4" />
168
+ <path d="M6 8.4v7.2" />
169
+ <path d="M18 15.6V12a4 4 0 0 0-4-4h-2.5" />
170
+ <path d="m13.5 5.5-2.5 2.5 2.5 2.5" />
171
+ </Svg>
172
+ );
173
+
174
+ /** Balão de conversa — aba de comentários. */
175
+ export const MessageSquare = (p: IconProps) => (
176
+ <Svg {...p}>
177
+ <path d="M21 15a3 3 0 0 1-3 3H8l-5 3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3z" />
178
+ </Svg>
179
+ );
@@ -2,7 +2,7 @@
2
2
  * Entry do cliente (Preact): monta o `<App />` no `#app`.
3
3
  *
4
4
  * Bundle gerado em memória por `Bun.build` (`client-build.ts`). Os componentes
5
- * vivem em arquivos irmãos nesta pasta (`App.tsx`, `RunList.tsx`, ...).
5
+ * vivem em arquivos irmãos nesta pasta (`App.tsx`, `Sidebar.tsx`, ...).
6
6
  */
7
7
 
8
8
  import { render } from "preact";
@@ -245,4 +245,6 @@ export const TAB_CAPTIONS = {
245
245
  "Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
246
246
  artifacts:
247
247
  "Artefatos persistidos da run: relatórios, briefs de auditoria, manifesto, classificação, Jev, contexto da PR e verificação. JSON com destaque de sintaxe; markdown renderizado com highlight de código.",
248
+ comments:
249
+ "Comentários da PR vinculada à branch: threads de revisão agrupadas por arquivo e comentários gerais. Carregados sob demanda ao abrir esta aba.",
248
250
  } as const;
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Helpers de apresentação: data/hora das runs e link para abrir o arquivo no
3
- * VS Code (`vscode://file/...`).
2
+ * Helpers de apresentação: data/hora das runs e links para abrir o arquivo no
3
+ * editor detectado pelo servidor (`vscode://` ou `jetbrains://`).
4
4
  */
5
5
 
6
+ import type { EditorContext } from "./types";
7
+
6
8
  /** Data/hora curta em pt-BR — ex.: `14 de out., 17:48`. */
7
9
  export function formatRunTime(iso?: string): string {
8
10
  if (!iso) return "";
@@ -38,3 +40,46 @@ export function vscodeFileLink(file: string, line?: number | null): string | nul
38
40
  );
39
41
  return `vscode://file/${encodeURI(normalized)}${line ? `:${line}` : ""}`;
40
42
  }
43
+
44
+ /**
45
+ * Link `jetbrains://<produto>/navigate/reference?project=<nome>&path=<arquivo>`.
46
+ * O IDE espera o caminho absoluto com a linha no fim (`arquivo:linha`).
47
+ */
48
+ export function jetbrainsFileLink(
49
+ file: string,
50
+ line: number | null | undefined,
51
+ project: string,
52
+ product: string,
53
+ ): string | null {
54
+ const path = file.replace(/\\/g, "/");
55
+ if (!/^([a-zA-Z]:\/|\/)/.test(path)) return null;
56
+ const target = `${path}${line ? `:${line}` : ""}`;
57
+ return `jetbrains://${product}/navigate/reference?project=${encodeURIComponent(
58
+ project,
59
+ )}&path=${encodeURIComponent(target)}`;
60
+ }
61
+
62
+ /** Monta o link de código conforme o editor detectado (null = sem link). */
63
+ export function editorFileLink(
64
+ file: string,
65
+ line: number | null | undefined,
66
+ editor: EditorContext,
67
+ ): string | null {
68
+ if (editor.kind === "vscode") return vscodeFileLink(file, line);
69
+ if (editor.kind === "jetbrains") {
70
+ return jetbrainsFileLink(
71
+ file,
72
+ line,
73
+ editor.project ?? "",
74
+ editor.product ?? "idea",
75
+ );
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** Rótulo do editor para tooltips. */
81
+ export function editorLabel(editor: EditorContext): string {
82
+ if (editor.kind === "jetbrains") return "JetBrains";
83
+ if (editor.kind === "vscode") return "VS Code";
84
+ return "editor";
85
+ }