@payconductor/houndsman 0.6.0 → 0.8.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.
Files changed (71) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/server-command.ts +7 -1
  8. package/src/cli/commands/worktree-command.ts +118 -0
  9. package/src/cli/help.ts +13 -3
  10. package/src/cli/index.ts +14 -4
  11. package/src/core/ast/service.ts +7 -2
  12. package/src/core/brief.ts +1 -0
  13. package/src/core/commands/context.ts +9 -1
  14. package/src/core/commands/findings.ts +17 -6
  15. package/src/core/commands/review.ts +13 -0
  16. package/src/core/commands/scaffold.ts +4 -2
  17. package/src/core/config.ts +13 -0
  18. package/src/core/defaults.ts +8 -1
  19. package/src/core/engine/houndsman-engine.ts +26 -3
  20. package/src/core/engine/review-scope.ts +17 -22
  21. package/src/core/engine/review-session.ts +25 -2
  22. package/src/core/engine/review-workspace.ts +204 -0
  23. package/src/core/git-runner.ts +25 -13
  24. package/src/core/github-review.ts +10 -7
  25. package/src/core/jev/jev-detector.ts +10 -2
  26. package/src/core/orchestrator.ts +62 -13
  27. package/src/core/project-config.ts +12 -0
  28. package/src/core/services/gh-client.ts +38 -22
  29. package/src/core/services/git-repository.ts +46 -0
  30. package/src/core/services/pr-resolver.ts +147 -0
  31. package/src/core/services/worktree-manager.ts +269 -0
  32. package/src/core/storage/file/file-storage.ts +14 -1
  33. package/src/core/storage/paths.ts +30 -1
  34. package/src/core/storage/sqlite/mappers.ts +4 -0
  35. package/src/core/storage/sqlite/schema.ts +13 -3
  36. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  37. package/src/core/storage/types.ts +12 -0
  38. package/src/core/storage/work-area.ts +20 -1
  39. package/src/core/types.ts +2 -0
  40. package/src/harness/hunt/hunt-options.ts +17 -0
  41. package/src/harness/hunt/hunt-session.ts +7 -20
  42. package/src/index.ts +31 -0
  43. package/src/server/client/App.tsx +86 -22
  44. package/src/server/client/CommentsPanel.tsx +255 -0
  45. package/src/server/client/DeleteRunModal.tsx +163 -0
  46. package/src/server/client/FindingCard.tsx +12 -4
  47. package/src/server/client/FindingsList.tsx +4 -1
  48. package/src/server/client/ResumePanel.tsx +8 -8
  49. package/src/server/client/Sidebar.tsx +452 -0
  50. package/src/server/client/SubmitModal.tsx +61 -5
  51. package/src/server/client/icons.tsx +29 -0
  52. package/src/server/client/index.tsx +1 -1
  53. package/src/server/client/utils/constants.ts +2 -0
  54. package/src/server/client/utils/format.ts +47 -2
  55. package/src/server/client/utils/types.ts +108 -0
  56. package/src/server/editor.ts +129 -0
  57. package/src/server/index.ts +205 -3
  58. package/src/server/pr-comments.ts +256 -0
  59. package/src/server/pr-inbox.ts +213 -0
  60. package/src/server/run-repository.ts +136 -3
  61. package/src/skill/README.md +11 -3
  62. package/src/skill/SKILL.md +44 -10
  63. package/src/skill/hunt.md +33 -7
  64. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  65. package/src/tests/ai-core.test.ts +2 -2
  66. package/src/tests/cli.test.ts +35 -1
  67. package/src/tests/github-review.test.ts +9 -0
  68. package/src/tests/server-editor.test.ts +100 -0
  69. package/src/tests/server.test.ts +333 -2
  70. package/src/tests/worktree.test.ts +369 -0
  71. package/src/server/client/RunList.tsx +0 -75
@@ -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. */
@@ -110,6 +111,44 @@ describe("server API", () => {
110
111
  }
111
112
  });
112
113
 
114
+ test("DELETE /api/runs/:slug apaga review, findings, artefatos e staging", async () => {
115
+ const env = makeEnv();
116
+ try {
117
+ const handler = createRequestHandler(env.config, env);
118
+ const res = await handler(
119
+ new Request("http://localhost/api/runs/main", { method: "DELETE" }),
120
+ );
121
+ expect(res.status).toBe(200);
122
+ const body = (await res.json()) as { deleted: boolean };
123
+ expect(body.deleted).toBe(true);
124
+
125
+ expect(env.storage.getReview(env.reviewId)).toBeNull();
126
+ expect(env.storage.readFindings(env.reviewId)).toBeNull();
127
+ expect(env.storage.listArtifacts(env.reviewId)).toHaveLength(0);
128
+
129
+ const list = await get(handler, "/api/runs");
130
+ const after = (await list.json()) as { runs: unknown[] };
131
+ expect(after.runs).toHaveLength(0);
132
+ } finally {
133
+ env.cleanup();
134
+ }
135
+ });
136
+
137
+ test("DELETE /api/runs/:slug inexistente devolve 404", async () => {
138
+ const env = makeEnv();
139
+ try {
140
+ const handler = createRequestHandler(env.config, env);
141
+ const res = await handler(
142
+ new Request("http://localhost/api/runs/nao-existe", {
143
+ method: "DELETE",
144
+ }),
145
+ );
146
+ expect(res.status).toBe(404);
147
+ } finally {
148
+ env.cleanup();
149
+ }
150
+ });
151
+
113
152
  test("GET findings reconstrói do artefato e PATCH atualiza o estado", async () => {
114
153
  const env = makeEnv();
115
154
  try {
@@ -220,11 +259,12 @@ describe("server API", () => {
220
259
  const body = (await res.json()) as {
221
260
  available: boolean;
222
261
  target: { number: number };
223
- candidates: unknown[];
262
+ candidates: { decision: string }[];
224
263
  };
225
264
  expect(body.available).toBe(true);
226
265
  expect(body.target.number).toBe(12);
227
266
  expect(body.candidates).toHaveLength(1);
267
+ expect(body.candidates[0].decision).toBe("pending");
228
268
  } finally {
229
269
  env.cleanup();
230
270
  }
@@ -445,3 +485,294 @@ describe("startServer", () => {
445
485
  }
446
486
  });
447
487
  });
488
+
489
+ describe("server — editor, PRs e comentários", () => {
490
+ const gitOk = (stdout = "") => ({ ok: true, code: 0, stdout, stderr: "" });
491
+
492
+ test("GET /api/editor devolve o contexto injetado", async () => {
493
+ const env = makeEnv();
494
+ try {
495
+ const handler = createRequestHandler(env.config, {
496
+ storage: env.storage,
497
+ repo: env.repo,
498
+ editor: { kind: "jetbrains", project: "app", product: "webstorm" },
499
+ });
500
+ const body = await (await get(handler, "/api/editor")).json();
501
+ expect(body).toEqual({
502
+ kind: "jetbrains",
503
+ project: "app",
504
+ product: "webstorm",
505
+ });
506
+ } finally {
507
+ env.cleanup();
508
+ }
509
+ });
510
+
511
+ test("GET /api/prs lista PRs abertas e casa com a run da branch", async () => {
512
+ const env = makeEnv();
513
+ try {
514
+ env.storage.getOrCreateReview({
515
+ repo: env.repo,
516
+ branch: "feat/x",
517
+ slug: "feat-x",
518
+ });
519
+ const calls: string[][] = [];
520
+ const handler = createRequestHandler(env.config, {
521
+ storage: env.storage,
522
+ repo: env.repo,
523
+ gitRunner: (args) => {
524
+ if (args[0] === "status") return gitOk("");
525
+ if (args[0] === "branch") return gitOk("main\n");
526
+ return gitOk("");
527
+ },
528
+ ghRunner: (args) => {
529
+ calls.push(args);
530
+ return ghJson([
531
+ {
532
+ number: 7,
533
+ title: "Feat X",
534
+ headRefName: "feat/x",
535
+ baseRefName: "main",
536
+ author: { login: "ana" },
537
+ updatedAt: "2026-01-02T00:00:00Z",
538
+ isDraft: false,
539
+ reviewDecision: "APPROVED",
540
+ url: "https://github.com/acme/app/pull/7",
541
+ },
542
+ {
543
+ number: 8,
544
+ title: "Outra",
545
+ headRefName: "feat/y",
546
+ baseRefName: "main",
547
+ author: { login: "bia" },
548
+ updatedAt: "2026-01-03T00:00:00Z",
549
+ isDraft: true,
550
+ reviewDecision: "",
551
+ url: "https://github.com/acme/app/pull/8",
552
+ },
553
+ ]);
554
+ },
555
+ });
556
+
557
+ const res = await get(handler, "/api/prs");
558
+ expect(res.status).toBe(200);
559
+ const body = (await res.json()) as {
560
+ available: boolean;
561
+ currentBranch: string;
562
+ worktree: { clean: boolean };
563
+ prs: { number: number; runSlug?: string }[];
564
+ };
565
+ expect(body.available).toBe(true);
566
+ expect(body.currentBranch).toBe("main");
567
+ expect(body.worktree.clean).toBe(true);
568
+ expect(body.prs.map((pr) => pr.number)).toEqual([8, 7]);
569
+ expect(body.prs.find((pr) => pr.number === 7)?.runSlug).toBe("feat-x");
570
+ expect(calls[0]).toEqual([
571
+ "pr",
572
+ "list",
573
+ "--state",
574
+ "open",
575
+ "--limit",
576
+ "50",
577
+ "--json",
578
+ "number,title,headRefName,baseRefName,author,updatedAt,isDraft,reviewDecision,url",
579
+ ]);
580
+ } finally {
581
+ env.cleanup();
582
+ }
583
+ });
584
+
585
+ test("POST /api/prs/checkout bloqueia árvore suja sem chamar gh", async () => {
586
+ const env = makeEnv();
587
+ try {
588
+ let ghCalls = 0;
589
+ const handler = createRequestHandler(env.config, {
590
+ storage: env.storage,
591
+ repo: env.repo,
592
+ gitRunner: (args) =>
593
+ args[0] === "status" ? gitOk(" M src/alpha/f.ts\n") : gitOk(""),
594
+ ghRunner: () => {
595
+ ghCalls++;
596
+ return gitOk("");
597
+ },
598
+ });
599
+
600
+ const res = await handler(
601
+ new Request("http://localhost/api/prs/checkout", {
602
+ method: "POST",
603
+ headers: { "content-type": "application/json" },
604
+ body: JSON.stringify({ number: 7 }),
605
+ }),
606
+ );
607
+ const body = (await res.json()) as {
608
+ ok: boolean;
609
+ reason: string;
610
+ changes?: string[];
611
+ };
612
+ expect(body.ok).toBe(false);
613
+ expect(body.reason).toBe("dirty");
614
+ expect(body.changes).toEqual([" M src/alpha/f.ts"]);
615
+ expect(ghCalls).toBe(0);
616
+ } finally {
617
+ env.cleanup();
618
+ }
619
+ });
620
+
621
+ test("POST /api/prs/checkout usa gh pr checkout quando a árvore está limpa", async () => {
622
+ const env = makeEnv();
623
+ try {
624
+ const calls: string[][] = [];
625
+ const handler = createRequestHandler(env.config, {
626
+ storage: env.storage,
627
+ repo: env.repo,
628
+ gitRunner: (args) => {
629
+ if (args[0] === "status") return gitOk("");
630
+ if (args[0] === "branch") return gitOk("feat/x\n");
631
+ return gitOk("");
632
+ },
633
+ ghRunner: (args) => {
634
+ calls.push(args);
635
+ return gitOk("");
636
+ },
637
+ });
638
+
639
+ const res = await handler(
640
+ new Request("http://localhost/api/prs/checkout", {
641
+ method: "POST",
642
+ headers: { "content-type": "application/json" },
643
+ body: JSON.stringify({ number: 7 }),
644
+ }),
645
+ );
646
+ expect(await res.json()).toEqual({
647
+ ok: true,
648
+ number: 7,
649
+ branch: "feat/x",
650
+ });
651
+ expect(calls).toContainEqual(["pr", "checkout", "7"]);
652
+ } finally {
653
+ env.cleanup();
654
+ }
655
+ });
656
+
657
+ test("GET comments agrupa threads, usa cache e respeita ?refresh=1", async () => {
658
+ const env = makeEnv();
659
+ try {
660
+ clearPrCommentsCache();
661
+ let inlineCalls = 0;
662
+ let issueCalls = 0;
663
+ const handler = createRequestHandler(env.config, {
664
+ storage: env.storage,
665
+ repo: env.repo,
666
+ ghRunner: (args) => {
667
+ if (args[0] === "pr") {
668
+ return ghJson({
669
+ number: 12,
670
+ url: "https://github.com/acme/app/pull/12",
671
+ headRefOid: "deadbeef",
672
+ });
673
+ }
674
+ const path = args[args.length - 1];
675
+ if (path === "repos/acme/app/pulls/12/comments") {
676
+ inlineCalls++;
677
+ return ghJson([
678
+ [
679
+ {
680
+ id: 1,
681
+ body: "raiz",
682
+ path: "src/alpha/f.ts",
683
+ line: 2,
684
+ created_at: "2026-01-01T00:00:00Z",
685
+ html_url:
686
+ "https://github.com/acme/app/pull/12#discussion_r1",
687
+ user: { login: "ana", type: "User" },
688
+ },
689
+ {
690
+ id: 2,
691
+ body: "resposta",
692
+ path: "src/alpha/f.ts",
693
+ line: 2,
694
+ in_reply_to_id: 1,
695
+ created_at: "2026-01-01T00:01:00Z",
696
+ user: { login: "bia", type: "User" },
697
+ },
698
+ ],
699
+ ]);
700
+ }
701
+ if (path === "repos/acme/app/issues/12/comments") {
702
+ issueCalls++;
703
+ return ghJson([
704
+ [
705
+ {
706
+ id: 3,
707
+ body: "geral",
708
+ created_at: "2026-01-01T00:02:00Z",
709
+ user: { login: "copilot[bot]", type: "Bot" },
710
+ },
711
+ ],
712
+ ]);
713
+ }
714
+ return { ok: false, code: 404, stdout: "", stderr: "not found" };
715
+ },
716
+ });
717
+
718
+ const first = (await (
719
+ await get(handler, "/api/runs/main/comments")
720
+ ).json()) as {
721
+ available: boolean;
722
+ counts: { threads: number; inline: number; issue: number };
723
+ threads: {
724
+ path: string;
725
+ absFile: string;
726
+ comments: { author: string }[];
727
+ }[];
728
+ issueComments: { authorType: string }[];
729
+ };
730
+ expect(first.available).toBe(true);
731
+ expect(first.counts).toEqual({ threads: 1, inline: 2, issue: 1 });
732
+ expect(first.threads[0].path).toBe("src/alpha/f.ts");
733
+ expect(first.threads[0].absFile).toBe(
734
+ resolve(env.repo.root, "src/alpha/f.ts"),
735
+ );
736
+ expect(first.threads[0].comments.map((c) => c.author)).toEqual([
737
+ "ana",
738
+ "bia",
739
+ ]);
740
+ expect(first.issueComments[0].authorType).toBe("Bot");
741
+
742
+ await get(handler, "/api/runs/main/comments");
743
+ expect(inlineCalls).toBe(1);
744
+ expect(issueCalls).toBe(1);
745
+
746
+ await get(handler, "/api/runs/main/comments?refresh=1");
747
+ expect(inlineCalls).toBe(2);
748
+ expect(issueCalls).toBe(2);
749
+ } finally {
750
+ clearPrCommentsCache();
751
+ env.cleanup();
752
+ }
753
+ });
754
+
755
+ test("GET comments é fail-open quando o gh não está disponível", async () => {
756
+ const env = makeEnv();
757
+ try {
758
+ clearPrCommentsCache();
759
+ const handler = createRequestHandler(env.config, {
760
+ storage: env.storage,
761
+ repo: env.repo,
762
+ ghRunner: () => ({
763
+ ok: false,
764
+ code: 127,
765
+ stdout: "",
766
+ stderr: "gh: not found",
767
+ }),
768
+ });
769
+ const body = (await (
770
+ await get(handler, "/api/runs/main/comments")
771
+ ).json()) as { available: boolean; reason: string };
772
+ expect(body.available).toBe(false);
773
+ expect(body.reason).toContain("gh");
774
+ } finally {
775
+ env.cleanup();
776
+ }
777
+ });
778
+ });