@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.
- package/package.json +1 -1
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/help.ts +1 -1
- package/src/core/commands/findings.ts +6 -1
- package/src/core/defaults.ts +1 -1
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/services/gh-client.ts +38 -22
- package/src/server/client/App.tsx +38 -12
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/Sidebar.tsx +397 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +19 -0
- package/src/server/client/index.tsx +1 -1
- package/src/server/client/utils/constants.ts +2 -0
- package/src/server/client/utils/format.ts +47 -2
- package/src/server/client/utils/types.ts +82 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +112 -3
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +202 -0
- package/src/server/run-repository.ts +33 -1
- package/src/tests/ai-core.test.ts +2 -2
- package/src/tests/cli.test.ts +33 -0
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +295 -2
- package/src/server/client/RunList.tsx +0 -75
package/src/tests/server.test.ts
CHANGED
|
@@ -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:
|
|
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
|
-
}
|