@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.
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Envio dos findings como comentários na Pull Request (biblioteca), via GitHub
3
+ * CLI (`gh`) — a capability de **"enviar revisão"**.
4
+ *
5
+ * Um comentário por finding via `POST repos/{repo}/pulls/{n}/comments`:
6
+ * **na linha** do diff quando o finding tem linha; **no arquivo**
7
+ * (`subject_type: file`) quando não tem. Se nenhum dos dois colar (ex.: arquivo
8
+ * fora do diff), cai em **um** comentário geral da PR. O `id`/URL de cada
9
+ * comentário é gravado no finding (`FindingState.github`): evita reenvio e
10
+ * mantém o vínculo local↔remoto.
11
+ *
12
+ * O `gh` é OPCIONAL e falha-aberto: sem binário/login/PR a prévia volta
13
+ * `available:false` e o envio é no-op (como o resto do houndsman).
14
+ */
15
+
16
+ import {
17
+ compareFindings,
18
+ type FindingDecision,
19
+ type FindingExecState,
20
+ type FindingState,
21
+ type FindingsStateFile,
22
+ } from "./findings-state";
23
+ import { SEVERITY_EMOJI, type Severity } from "./types";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Runner do `gh` (injetável nos testes)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type GhResult = { ok: boolean; code: number; stdout: string; stderr: string };
30
+
31
+ /** Executa o `gh` e devolve o resultado cru (sem lançar). */
32
+ export type GhRunner = (args: string[]) => GhResult;
33
+
34
+ /** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
35
+ export const defaultGhRunner: GhRunner = (args) => {
36
+ try {
37
+ const result = Bun.spawnSync(["gh", ...args], {
38
+ stdout: "pipe",
39
+ stderr: "pipe",
40
+ });
41
+ const code = result.exitCode ?? 1;
42
+ return {
43
+ ok: code === 0,
44
+ code,
45
+ stdout: new TextDecoder().decode(result.stdout).trim(),
46
+ stderr: new TextDecoder().decode(result.stderr).trim(),
47
+ };
48
+ } catch (error) {
49
+ return {
50
+ ok: false,
51
+ code: 127,
52
+ stdout: "",
53
+ stderr: error instanceof Error ? error.message : String(error),
54
+ };
55
+ }
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Alvo (PR + repositório)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export type PrTarget = {
63
+ /** Número da PR. */
64
+ number: number;
65
+ /** `owner/name`. */
66
+ repo: string;
67
+ /** SHA do head revisado (base do `commit_id` dos comentários). */
68
+ headSha?: string;
69
+ url?: string;
70
+ };
71
+
72
+ export type TargetResult = { ok: true; target: PrTarget } | { ok: false; reason: string };
73
+
74
+ /** Extrai `owner/name` de uma URL de PR do GitHub. `null` se não casar. */
75
+ function repoFromUrl(url: string | undefined): string | null {
76
+ if (!url) return null;
77
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/);
78
+ return match ? `${match[1]}/${match[2]}` : null;
79
+ }
80
+
81
+ /** Mensagem curta para um erro do `gh`. */
82
+ function shortError(result: GhResult): string {
83
+ if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
84
+ return "gh não instalado.";
85
+ }
86
+ const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
87
+ if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
88
+ return last || `gh saiu com código ${result.code}.`;
89
+ }
90
+
91
+ /**
92
+ * Resolve a PR da branch + o repositório via `gh pr view`. Fail-open: devolve
93
+ * `{ ok:false, reason }` quando não há `gh`, login ou PR.
94
+ */
95
+ export function resolvePrTarget(runner: GhRunner, branch: string): TargetResult {
96
+ const view = runner(["pr", "view", branch, "--json", "number,url,headRefOid"]);
97
+ if (!view.ok) return { ok: false, reason: shortError(view) };
98
+
99
+ let payload: { number?: number; url?: string; headRefOid?: string };
100
+ try {
101
+ payload = JSON.parse(view.stdout) as typeof payload;
102
+ } catch {
103
+ return { ok: false, reason: "Resposta inválida do gh." };
104
+ }
105
+
106
+ const repo = repoFromUrl(payload.url);
107
+ if (!payload.number || !repo) {
108
+ return { ok: false, reason: "Não foi possível identificar a PR/repositório." };
109
+ }
110
+ return {
111
+ ok: true,
112
+ target: {
113
+ number: payload.number,
114
+ repo,
115
+ headSha: payload.headRefOid,
116
+ url: payload.url,
117
+ },
118
+ };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Seleção
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export type SubmitSelection = {
126
+ /** Severidades incluídas (vazio = todas). */
127
+ severities?: Severity[];
128
+ /** Estados de execução incluídos (vazio = todos). */
129
+ execStates?: FindingExecState[];
130
+ /** Decisões incluídas (default: `pending` + `execute` — nunca `ignore`). */
131
+ decisions?: FindingDecision[];
132
+ /** Reenvia findings já vinculados a um comentário. */
133
+ includeSent?: boolean;
134
+ };
135
+
136
+ /** Decisões elegíveis por padrão — o `ignore` (descartado na triagem) nunca sai. */
137
+ export const DEFAULT_SUBMIT_DECISIONS: readonly FindingDecision[] = [
138
+ "pending",
139
+ "execute",
140
+ ];
141
+
142
+ export type SubmitCandidate = {
143
+ id: string;
144
+ file: string;
145
+ line: number | null;
146
+ severity: Severity;
147
+ execState: FindingExecState;
148
+ rule: string;
149
+ message: string;
150
+ /** Onde o comentário será ancorado: na linha do diff ou no arquivo. */
151
+ kind: "line" | "file";
152
+ /** Já vinculado a um comentário remoto. */
153
+ sent: boolean;
154
+ /** URL do comentário já enviado (quando houver). */
155
+ sentUrl?: string;
156
+ /** Será pulado (já enviado e `includeSent` desligado). */
157
+ skip: boolean;
158
+ };
159
+
160
+ /**
161
+ * Seleciona os findings elegíveis ao envio: presentes, decisão elegível (sem
162
+ * `ignore`), casando os filtros de severidade/execução. Marca `sent`/`skip`.
163
+ */
164
+ export function selectSubmitCandidates(
165
+ state: FindingsStateFile,
166
+ selection: SubmitSelection = {},
167
+ ): SubmitCandidate[] {
168
+ const severities = new Set<Severity>(selection.severities ?? []);
169
+ const execStates = new Set<FindingExecState>(selection.execStates ?? []);
170
+ const decisions = new Set<FindingDecision>(
171
+ selection.decisions ?? DEFAULT_SUBMIT_DECISIONS,
172
+ );
173
+ const includeSent = selection.includeSent ?? false;
174
+
175
+ return Object.values(state.findings)
176
+ .filter((finding) => finding.present)
177
+ .filter((finding) => decisions.has(finding.decision))
178
+ .filter((finding) => severities.size === 0 || severities.has(finding.severity))
179
+ .filter((finding) => execStates.size === 0 || execStates.has(finding.execState))
180
+ .sort(compareFindings)
181
+ .map((finding) => {
182
+ const sent = Boolean(finding.github?.commentId);
183
+ return {
184
+ id: finding.id,
185
+ file: finding.file,
186
+ line: finding.line,
187
+ severity: finding.severity,
188
+ execState: finding.execState,
189
+ rule: finding.rule,
190
+ message: finding.message,
191
+ kind: finding.line !== null ? "line" : "file",
192
+ sent,
193
+ sentUrl: finding.github?.url,
194
+ skip: sent && !includeSent,
195
+ };
196
+ });
197
+ }
198
+
199
+ export type SubmitPreview = {
200
+ available: boolean;
201
+ reason?: string;
202
+ target?: PrTarget;
203
+ candidates: SubmitCandidate[];
204
+ totals: {
205
+ /** Total de candidatos (após os filtros). */
206
+ candidates: number;
207
+ /** Quantos serão efetivamente enviados. */
208
+ toSend: number;
209
+ /** Comentários ancorados na linha do diff. */
210
+ line: number;
211
+ /** Comentários ancorados no arquivo (sem linha específica). */
212
+ file: number;
213
+ /** Já enviados (no total de candidatos). */
214
+ sent: number;
215
+ };
216
+ };
217
+
218
+ /** Monta a prévia do envio a partir do estado + alvo resolvido. */
219
+ export function buildSubmitPreview(
220
+ state: FindingsStateFile,
221
+ target: TargetResult,
222
+ selection: SubmitSelection = {},
223
+ ): SubmitPreview {
224
+ const candidates = selectSubmitCandidates(state, selection);
225
+ const toSend = candidates.filter((candidate) => !candidate.skip);
226
+ return {
227
+ available: target.ok,
228
+ reason: target.ok ? undefined : target.reason,
229
+ target: target.ok ? target.target : undefined,
230
+ candidates,
231
+ totals: {
232
+ candidates: candidates.length,
233
+ toSend: toSend.length,
234
+ line: toSend.filter((candidate) => candidate.kind === "line").length,
235
+ file: toSend.filter((candidate) => candidate.kind === "file").length,
236
+ sent: candidates.filter((candidate) => candidate.sent).length,
237
+ },
238
+ };
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Corpo dos comentários
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /** Corpo (markdown) do comentário inline de um finding. */
246
+ export function buildCommentBody(finding: FindingState): string {
247
+ const lines = [
248
+ `${SEVERITY_EMOJI[finding.severity]} **${finding.rule}** · _${finding.severity}_`,
249
+ "",
250
+ finding.message,
251
+ "",
252
+ `**Ação:** ${finding.action}`,
253
+ ];
254
+ if (finding.evidence) lines.push("", "```", finding.evidence, "```");
255
+ lines.push("", "_— houndsman_");
256
+ return lines.join("\n");
257
+ }
258
+
259
+ /** Corpo (markdown) do comentário geral com os findings sem posição no diff. */
260
+ export function renderGeneralBody(findings: readonly FindingState[]): string {
261
+ const lines = [
262
+ "## Revisão do houndsman",
263
+ "",
264
+ "Achados sem posição no diff (comentados aqui):",
265
+ "",
266
+ ];
267
+ for (const finding of findings) {
268
+ const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
269
+ lines.push(
270
+ `- ${SEVERITY_EMOJI[finding.severity]} **${finding.severity}** \`${location}\` — ${finding.rule}`,
271
+ ` ${finding.message}`,
272
+ );
273
+ }
274
+ return lines.join("\n");
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Envio
279
+ // ---------------------------------------------------------------------------
280
+
281
+ export type PostedComment = {
282
+ id: string;
283
+ kind: "line" | "file" | "comment";
284
+ commentId: number;
285
+ url?: string;
286
+ };
287
+
288
+ export type SubmitOutcome = {
289
+ posted: PostedComment[];
290
+ failed: { id: string; reason: string }[];
291
+ skipped: { id: string; reason: string }[];
292
+ /** Comentário geral (quando houve findings sem posição/falha de inline). */
293
+ general?: { commentId: number; url?: string; ids: string[] };
294
+ };
295
+
296
+ type PostResult =
297
+ | { ok: true; commentId: number; url?: string }
298
+ | { ok: false; reason: string };
299
+
300
+ function parseComment(result: GhResult): PostResult {
301
+ if (!result.ok) return { ok: false, reason: shortError(result) };
302
+ try {
303
+ const payload = JSON.parse(result.stdout) as { id?: number; html_url?: string };
304
+ if (!payload.id) return { ok: false, reason: "resposta sem id." };
305
+ return { ok: true, commentId: payload.id, url: payload.html_url };
306
+ } catch {
307
+ return { ok: false, reason: "resposta inválida do gh." };
308
+ }
309
+ }
310
+
311
+ function commentArgs(
312
+ target: PrTarget,
313
+ finding: FindingState,
314
+ mode: "line" | "file",
315
+ ): string[] {
316
+ const args = [
317
+ "api",
318
+ `repos/${target.repo}/pulls/${target.number}/comments`,
319
+ "-f",
320
+ `body=${buildCommentBody(finding)}`,
321
+ "-f",
322
+ `path=${finding.file}`,
323
+ ];
324
+ if (mode === "line") {
325
+ args.push("-F", `line=${finding.line}`, "-f", "side=RIGHT");
326
+ } else {
327
+ // Sem linha específica: ancora no arquivo (comentário de nível de arquivo).
328
+ args.push("-f", "subject_type=file");
329
+ }
330
+ if (target.headSha) args.push("-f", `commit_id=${target.headSha}`);
331
+ return args;
332
+ }
333
+
334
+ /** Posta o comentário de review de um finding (na linha ou no arquivo). */
335
+ export function postComment(
336
+ runner: GhRunner,
337
+ target: PrTarget,
338
+ finding: FindingState,
339
+ mode: "line" | "file",
340
+ ): PostResult {
341
+ return parseComment(runner(commentArgs(target, finding, mode)));
342
+ }
343
+
344
+ /** Posta o comentário geral (fallback) com os findings sem posição no diff. */
345
+ export function postGeneral(
346
+ runner: GhRunner,
347
+ target: PrTarget,
348
+ findings: readonly FindingState[],
349
+ ): PostResult {
350
+ return parseComment(
351
+ runner([
352
+ "api",
353
+ `repos/${target.repo}/issues/${target.number}/comments`,
354
+ "-f",
355
+ `body=${renderGeneralBody(findings)}`,
356
+ ]),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * Envia os findings selecionados: um comentário por finding — na linha do diff
362
+ * ou no arquivo (`subject_type=file`). O que não colar em nenhum dos dois vai
363
+ * para um único comentário geral. Muta o `state` com o vínculo
364
+ * (`finding.github`); o caller persiste.
365
+ */
366
+ export function submitFindings(options: {
367
+ state: FindingsStateFile;
368
+ target: PrTarget;
369
+ runner: GhRunner;
370
+ selection?: SubmitSelection;
371
+ now?: string;
372
+ }): SubmitOutcome {
373
+ const { state, target, runner } = options;
374
+ const now = options.now ?? new Date().toISOString();
375
+ const candidates = selectSubmitCandidates(state, options.selection);
376
+
377
+ const outcome: SubmitOutcome = { posted: [], failed: [], skipped: [] };
378
+ const deferred: FindingState[] = [];
379
+
380
+ const link = (
381
+ finding: FindingState,
382
+ commentId: number,
383
+ url: string | undefined,
384
+ kind: "line" | "file" | "comment",
385
+ ): void => {
386
+ finding.github = { commentId, url, kind, pr: target.number, at: now };
387
+ finding.updatedAt = now;
388
+ };
389
+
390
+ for (const candidate of candidates) {
391
+ const finding = state.findings[candidate.id];
392
+ if (!finding) continue;
393
+
394
+ if (candidate.skip) {
395
+ outcome.skipped.push({ id: candidate.id, reason: "já enviado." });
396
+ continue;
397
+ }
398
+
399
+ // Tenta a linha (quando há) e depois o arquivo — o primeiro que colar vence.
400
+ const modes: ("line" | "file")[] =
401
+ candidate.kind === "line" ? ["line", "file"] : ["file"];
402
+ let posted: PostResult | null = null;
403
+ let reason = "";
404
+ for (const mode of modes) {
405
+ const attempt = postComment(runner, target, finding, mode);
406
+ if (attempt.ok) {
407
+ posted = attempt;
408
+ link(finding, attempt.commentId, attempt.url, mode);
409
+ outcome.posted.push({
410
+ id: candidate.id,
411
+ kind: mode,
412
+ commentId: attempt.commentId,
413
+ url: attempt.url,
414
+ });
415
+ break;
416
+ }
417
+ reason = attempt.reason;
418
+ }
419
+ if (!posted) {
420
+ outcome.failed.push({ id: candidate.id, reason });
421
+ deferred.push(finding);
422
+ }
423
+ }
424
+
425
+ if (deferred.length > 0) {
426
+ const general = postGeneral(runner, target, deferred);
427
+ if (general.ok) {
428
+ for (const finding of deferred) {
429
+ link(finding, general.commentId, general.url, "comment");
430
+ outcome.posted.push({
431
+ id: finding.id,
432
+ kind: "comment",
433
+ commentId: general.commentId,
434
+ url: general.url,
435
+ });
436
+ }
437
+ outcome.general = {
438
+ commentId: general.commentId,
439
+ url: general.url,
440
+ ids: deferred.map((finding) => finding.id),
441
+ };
442
+ } else {
443
+ for (const finding of deferred) {
444
+ if (!outcome.failed.some((entry) => entry.id === finding.id)) {
445
+ outcome.failed.push({ id: finding.id, reason: general.reason });
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ return outcome;
452
+ }