@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.
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
  import type { HoundsmanConfig } from "../core/config";
6
6
  import { buildStateFromFindings, fileReader } from "../core/findings-state";
7
7
  import { dedupeFindings, parseWatchdogReport } from "../core/report-builder";
@@ -10,6 +10,7 @@ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
10
10
  import type { RepoIdentity } from "../core/storage/types";
11
11
  import { createRequestHandler, startServer } from "../server";
12
12
  import { isSlug } from "../server/artifacts";
13
+ import { clearPrCommentsCache } from "../server/pr-comments";
13
14
  import { fakeHoundsmanConfig } from "./fixtures/fake-project";
14
15
 
15
16
  /** Ambiente fake: banco temporário + review `main` com um finding. */
@@ -220,11 +221,12 @@ describe("server API", () => {
220
221
  const body = (await res.json()) as {
221
222
  available: boolean;
222
223
  target: { number: number };
223
- candidates: unknown[];
224
+ candidates: { decision: string }[];
224
225
  };
225
226
  expect(body.available).toBe(true);
226
227
  expect(body.target.number).toBe(12);
227
228
  expect(body.candidates).toHaveLength(1);
229
+ expect(body.candidates[0].decision).toBe("pending");
228
230
  } finally {
229
231
  env.cleanup();
230
232
  }
@@ -445,3 +447,294 @@ describe("startServer", () => {
445
447
  }
446
448
  });
447
449
  });
450
+
451
+ describe("server — editor, PRs e comentários", () => {
452
+ const gitOk = (stdout = "") => ({ ok: true, code: 0, stdout, stderr: "" });
453
+
454
+ test("GET /api/editor devolve o contexto injetado", async () => {
455
+ const env = makeEnv();
456
+ try {
457
+ const handler = createRequestHandler(env.config, {
458
+ storage: env.storage,
459
+ repo: env.repo,
460
+ editor: { kind: "jetbrains", project: "app", product: "webstorm" },
461
+ });
462
+ const body = await (await get(handler, "/api/editor")).json();
463
+ expect(body).toEqual({
464
+ kind: "jetbrains",
465
+ project: "app",
466
+ product: "webstorm",
467
+ });
468
+ } finally {
469
+ env.cleanup();
470
+ }
471
+ });
472
+
473
+ test("GET /api/prs lista PRs abertas e casa com a run da branch", async () => {
474
+ const env = makeEnv();
475
+ try {
476
+ env.storage.getOrCreateReview({
477
+ repo: env.repo,
478
+ branch: "feat/x",
479
+ slug: "feat-x",
480
+ });
481
+ const calls: string[][] = [];
482
+ const handler = createRequestHandler(env.config, {
483
+ storage: env.storage,
484
+ repo: env.repo,
485
+ gitRunner: (args) => {
486
+ if (args[0] === "status") return gitOk("");
487
+ if (args[0] === "branch") return gitOk("main\n");
488
+ return gitOk("");
489
+ },
490
+ ghRunner: (args) => {
491
+ calls.push(args);
492
+ return ghJson([
493
+ {
494
+ number: 7,
495
+ title: "Feat X",
496
+ headRefName: "feat/x",
497
+ baseRefName: "main",
498
+ author: { login: "ana" },
499
+ updatedAt: "2026-01-02T00:00:00Z",
500
+ isDraft: false,
501
+ reviewDecision: "APPROVED",
502
+ url: "https://github.com/acme/app/pull/7",
503
+ },
504
+ {
505
+ number: 8,
506
+ title: "Outra",
507
+ headRefName: "feat/y",
508
+ baseRefName: "main",
509
+ author: { login: "bia" },
510
+ updatedAt: "2026-01-03T00:00:00Z",
511
+ isDraft: true,
512
+ reviewDecision: "",
513
+ url: "https://github.com/acme/app/pull/8",
514
+ },
515
+ ]);
516
+ },
517
+ });
518
+
519
+ const res = await get(handler, "/api/prs");
520
+ expect(res.status).toBe(200);
521
+ const body = (await res.json()) as {
522
+ available: boolean;
523
+ currentBranch: string;
524
+ worktree: { clean: boolean };
525
+ prs: { number: number; runSlug?: string }[];
526
+ };
527
+ expect(body.available).toBe(true);
528
+ expect(body.currentBranch).toBe("main");
529
+ expect(body.worktree.clean).toBe(true);
530
+ expect(body.prs.map((pr) => pr.number)).toEqual([8, 7]);
531
+ expect(body.prs.find((pr) => pr.number === 7)?.runSlug).toBe("feat-x");
532
+ expect(calls[0]).toEqual([
533
+ "pr",
534
+ "list",
535
+ "--state",
536
+ "open",
537
+ "--limit",
538
+ "50",
539
+ "--json",
540
+ "number,title,headRefName,baseRefName,author,updatedAt,isDraft,reviewDecision,url",
541
+ ]);
542
+ } finally {
543
+ env.cleanup();
544
+ }
545
+ });
546
+
547
+ test("POST /api/prs/checkout bloqueia árvore suja sem chamar gh", async () => {
548
+ const env = makeEnv();
549
+ try {
550
+ let ghCalls = 0;
551
+ const handler = createRequestHandler(env.config, {
552
+ storage: env.storage,
553
+ repo: env.repo,
554
+ gitRunner: (args) =>
555
+ args[0] === "status" ? gitOk(" M src/alpha/f.ts\n") : gitOk(""),
556
+ ghRunner: () => {
557
+ ghCalls++;
558
+ return gitOk("");
559
+ },
560
+ });
561
+
562
+ const res = await handler(
563
+ new Request("http://localhost/api/prs/checkout", {
564
+ method: "POST",
565
+ headers: { "content-type": "application/json" },
566
+ body: JSON.stringify({ number: 7 }),
567
+ }),
568
+ );
569
+ const body = (await res.json()) as {
570
+ ok: boolean;
571
+ reason: string;
572
+ changes?: string[];
573
+ };
574
+ expect(body.ok).toBe(false);
575
+ expect(body.reason).toBe("dirty");
576
+ expect(body.changes).toEqual([" M src/alpha/f.ts"]);
577
+ expect(ghCalls).toBe(0);
578
+ } finally {
579
+ env.cleanup();
580
+ }
581
+ });
582
+
583
+ test("POST /api/prs/checkout usa gh pr checkout quando a árvore está limpa", async () => {
584
+ const env = makeEnv();
585
+ try {
586
+ const calls: string[][] = [];
587
+ const handler = createRequestHandler(env.config, {
588
+ storage: env.storage,
589
+ repo: env.repo,
590
+ gitRunner: (args) => {
591
+ if (args[0] === "status") return gitOk("");
592
+ if (args[0] === "branch") return gitOk("feat/x\n");
593
+ return gitOk("");
594
+ },
595
+ ghRunner: (args) => {
596
+ calls.push(args);
597
+ return gitOk("");
598
+ },
599
+ });
600
+
601
+ const res = await handler(
602
+ new Request("http://localhost/api/prs/checkout", {
603
+ method: "POST",
604
+ headers: { "content-type": "application/json" },
605
+ body: JSON.stringify({ number: 7 }),
606
+ }),
607
+ );
608
+ expect(await res.json()).toEqual({
609
+ ok: true,
610
+ number: 7,
611
+ branch: "feat/x",
612
+ });
613
+ expect(calls).toContainEqual(["pr", "checkout", "7"]);
614
+ } finally {
615
+ env.cleanup();
616
+ }
617
+ });
618
+
619
+ test("GET comments agrupa threads, usa cache e respeita ?refresh=1", async () => {
620
+ const env = makeEnv();
621
+ try {
622
+ clearPrCommentsCache();
623
+ let inlineCalls = 0;
624
+ let issueCalls = 0;
625
+ const handler = createRequestHandler(env.config, {
626
+ storage: env.storage,
627
+ repo: env.repo,
628
+ ghRunner: (args) => {
629
+ if (args[0] === "pr") {
630
+ return ghJson({
631
+ number: 12,
632
+ url: "https://github.com/acme/app/pull/12",
633
+ headRefOid: "deadbeef",
634
+ });
635
+ }
636
+ const path = args[args.length - 1];
637
+ if (path === "repos/acme/app/pulls/12/comments") {
638
+ inlineCalls++;
639
+ return ghJson([
640
+ [
641
+ {
642
+ id: 1,
643
+ body: "raiz",
644
+ path: "src/alpha/f.ts",
645
+ line: 2,
646
+ created_at: "2026-01-01T00:00:00Z",
647
+ html_url:
648
+ "https://github.com/acme/app/pull/12#discussion_r1",
649
+ user: { login: "ana", type: "User" },
650
+ },
651
+ {
652
+ id: 2,
653
+ body: "resposta",
654
+ path: "src/alpha/f.ts",
655
+ line: 2,
656
+ in_reply_to_id: 1,
657
+ created_at: "2026-01-01T00:01:00Z",
658
+ user: { login: "bia", type: "User" },
659
+ },
660
+ ],
661
+ ]);
662
+ }
663
+ if (path === "repos/acme/app/issues/12/comments") {
664
+ issueCalls++;
665
+ return ghJson([
666
+ [
667
+ {
668
+ id: 3,
669
+ body: "geral",
670
+ created_at: "2026-01-01T00:02:00Z",
671
+ user: { login: "copilot[bot]", type: "Bot" },
672
+ },
673
+ ],
674
+ ]);
675
+ }
676
+ return { ok: false, code: 404, stdout: "", stderr: "not found" };
677
+ },
678
+ });
679
+
680
+ const first = (await (
681
+ await get(handler, "/api/runs/main/comments")
682
+ ).json()) as {
683
+ available: boolean;
684
+ counts: { threads: number; inline: number; issue: number };
685
+ threads: {
686
+ path: string;
687
+ absFile: string;
688
+ comments: { author: string }[];
689
+ }[];
690
+ issueComments: { authorType: string }[];
691
+ };
692
+ expect(first.available).toBe(true);
693
+ expect(first.counts).toEqual({ threads: 1, inline: 2, issue: 1 });
694
+ expect(first.threads[0].path).toBe("src/alpha/f.ts");
695
+ expect(first.threads[0].absFile).toBe(
696
+ resolve(env.repo.root, "src/alpha/f.ts"),
697
+ );
698
+ expect(first.threads[0].comments.map((c) => c.author)).toEqual([
699
+ "ana",
700
+ "bia",
701
+ ]);
702
+ expect(first.issueComments[0].authorType).toBe("Bot");
703
+
704
+ await get(handler, "/api/runs/main/comments");
705
+ expect(inlineCalls).toBe(1);
706
+ expect(issueCalls).toBe(1);
707
+
708
+ await get(handler, "/api/runs/main/comments?refresh=1");
709
+ expect(inlineCalls).toBe(2);
710
+ expect(issueCalls).toBe(2);
711
+ } finally {
712
+ clearPrCommentsCache();
713
+ env.cleanup();
714
+ }
715
+ });
716
+
717
+ test("GET comments é fail-open quando o gh não está disponível", async () => {
718
+ const env = makeEnv();
719
+ try {
720
+ clearPrCommentsCache();
721
+ const handler = createRequestHandler(env.config, {
722
+ storage: env.storage,
723
+ repo: env.repo,
724
+ ghRunner: () => ({
725
+ ok: false,
726
+ code: 127,
727
+ stdout: "",
728
+ stderr: "gh: not found",
729
+ }),
730
+ });
731
+ const body = (await (
732
+ await get(handler, "/api/runs/main/comments")
733
+ ).json()) as { available: boolean; reason: string };
734
+ expect(body.available).toBe(false);
735
+ expect(body.reason).toContain("gh");
736
+ } finally {
737
+ env.cleanup();
738
+ }
739
+ });
740
+ });
@@ -1,75 +0,0 @@
1
- /**
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.
4
- */
5
-
6
- import { BranchIcon, Clock, Sparkles } from "./icons";
7
- import { describeRunStatus } from "./utils/constants";
8
- import { formatRunTime } from "./utils/format";
9
- import type { RunSummary } from "./utils/types";
10
-
11
- export function RunList({
12
- runs,
13
- slug,
14
- onSelect,
15
- }: {
16
- runs: RunSummary[];
17
- slug: string | null;
18
- onSelect: (slug: string) => void;
19
- }) {
20
- return (
21
- <aside class="sidebar">
22
- <div class="brand">
23
- <img class="logo" src="/assets/logo.png" alt="" />
24
- <div>
25
- <h1>Houndsman</h1>
26
- <p class="sub">Revisões e triagem</p>
27
- </div>
28
- </div>
29
- <p class="section-label">Execuções</p>
30
- <nav class="runs">
31
- {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
32
- {runs.map((run) => {
33
- const status = describeRunStatus(run);
34
- const fixReady = status?.kind === "fix_done";
35
- return (
36
- <button
37
- type="button"
38
- title={`Abrir a run da branch ${run.branch}`}
39
- class={`run${run.slug === slug ? " active" : ""}`}
40
- onClick={() => onSelect(run.slug)}
41
- >
42
- <span class="run-top">
43
- <BranchIcon size={14} class="run-icon" />
44
- <span class="run-branch">{run.branch}</span>
45
- </span>
46
- <span class="run-meta">
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>
57
- </span>
58
- {fixReady ? (
59
- <span class="run-badge">
60
- <Sparkles size={12} /> Correção pronta
61
- </span>
62
- ) : (
63
- status && (
64
- <span class={`run-status status-${status.kind}`}>
65
- {status.label}
66
- </span>
67
- )
68
- )}
69
- </button>
70
- );
71
- })}
72
- </nav>
73
- </aside>
74
- );
75
- }