@payconductor/houndsman 0.4.0 → 0.5.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/README.md +12 -38
- package/package.json +1 -1
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/core/commands/findings.ts +61 -5
- package/src/core/commands/status.ts +63 -0
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/findings-state.ts +21 -4
- package/src/core/github-review.ts +620 -57
- package/src/core/orchestrator.ts +98 -8
- package/src/core/paths.ts +3 -0
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-status.ts +39 -5
- package/src/core/storage/file/file-storage.ts +2 -0
- package/src/core/storage/sqlite/mappers.ts +2 -0
- package/src/core/storage/sqlite/schema.ts +9 -1
- package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
- package/src/core/storage/types.ts +9 -0
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +12 -5
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +14 -10
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +92 -12
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +49 -6
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/{constants.ts → utils/constants.ts} +34 -5
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +58 -0
- package/src/server/index.ts +63 -5
- package/src/server/run-repository.ts +9 -0
- package/src/skill/example-review.md +22 -14
- package/src/skill/hunt.md +31 -17
- package/src/skill/verify.md +2 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +32 -30
- package/src/tests/baseline.test.ts +3 -0
- package/src/tests/code-highlight.test.ts +1 -1
- package/src/tests/findings-state.test.ts +50 -2
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/markdown.test.ts +61 -1
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +73 -0
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +53 -0
- package/src/tests/server.test.ts +76 -21
- package/src/tests/storage.test.ts +63 -0
- package/src/server/client/markdown.ts +0 -153
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
- /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
package/src/tests/server.test.ts
CHANGED
|
@@ -87,6 +87,11 @@ function makeEnv(): {
|
|
|
87
87
|
const get = (handler: (r: Request) => Promise<Response>, path: string) =>
|
|
88
88
|
handler(new Request(`http://localhost${path}`));
|
|
89
89
|
|
|
90
|
+
/** Resposta OK do `gh` com um JSON. */
|
|
91
|
+
function ghJson(payload: unknown) {
|
|
92
|
+
return { ok: true, code: 0, stderr: "", stdout: JSON.stringify(payload) };
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
describe("server API", () => {
|
|
91
96
|
test("GET /api/runs lista as runs com resumo", async () => {
|
|
92
97
|
const env = makeEnv();
|
|
@@ -231,27 +236,77 @@ describe("server API", () => {
|
|
|
231
236
|
const handler = createRequestHandler(env.config, {
|
|
232
237
|
storage: env.storage,
|
|
233
238
|
repo: env.repo,
|
|
234
|
-
ghRunner: (args) =>
|
|
235
|
-
args[0] === "pr"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
}
|
|
239
|
+
ghRunner: (args) => {
|
|
240
|
+
if (args[0] === "pr") {
|
|
241
|
+
return ghJson({
|
|
242
|
+
number: 12,
|
|
243
|
+
url: "https://github.com/acme/app/pull/12",
|
|
244
|
+
headRefOid: "deadbeef",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (args[1] === "graphql") {
|
|
248
|
+
const query = args.find((arg) => arg.startsWith("query=")) ?? "";
|
|
249
|
+
if (query.includes("states: [PENDING]")) {
|
|
250
|
+
return ghJson({
|
|
251
|
+
data: {
|
|
252
|
+
repository: {
|
|
253
|
+
pullRequest: {
|
|
254
|
+
id: "PR_1",
|
|
255
|
+
reviews: { nodes: [] },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (query.includes("addPullRequestReview(input")) {
|
|
262
|
+
return ghJson({
|
|
263
|
+
data: {
|
|
264
|
+
addPullRequestReview: {
|
|
265
|
+
pullRequestReview: {
|
|
266
|
+
id: "REV_NEW",
|
|
267
|
+
databaseId: 11,
|
|
268
|
+
resourcePath:
|
|
269
|
+
"/acme/app/pull/12#pullrequestreview-11",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (query.includes("addPullRequestReviewThread")) {
|
|
276
|
+
return ghJson({
|
|
277
|
+
data: {
|
|
278
|
+
addPullRequestReviewThread: {
|
|
279
|
+
thread: {
|
|
280
|
+
comments: {
|
|
281
|
+
nodes: [
|
|
282
|
+
{
|
|
283
|
+
databaseId: 501,
|
|
284
|
+
resourcePath:
|
|
285
|
+
"/acme/app/pull/12#discussion_r501",
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (query.includes("submitPullRequestReview")) {
|
|
295
|
+
return ghJson({
|
|
296
|
+
data: {
|
|
297
|
+
submitPullRequestReview: {
|
|
298
|
+
pullRequestReview: {
|
|
299
|
+
id: "REV_NEW",
|
|
300
|
+
databaseId: 11,
|
|
301
|
+
state: "CHANGES_REQUESTED",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { ok: false, code: 404, stderr: "", stdout: "" };
|
|
309
|
+
},
|
|
255
310
|
});
|
|
256
311
|
const res = await handler(
|
|
257
312
|
new Request("http://localhost/api/runs/main/submit", {
|
|
@@ -384,3 +384,66 @@ describe("resolveFindingId", () => {
|
|
|
384
384
|
});
|
|
385
385
|
});
|
|
386
386
|
});
|
|
387
|
+
|
|
388
|
+
describe("migração do schema SQLite", () => {
|
|
389
|
+
test("banco v1 ganha a coluna heartbeat_at sem perder reviews", () => {
|
|
390
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-migrate-"));
|
|
391
|
+
const path = join(dir, "houndsman.db");
|
|
392
|
+
const legacy = new Database(path);
|
|
393
|
+
try {
|
|
394
|
+
legacy.exec(`
|
|
395
|
+
CREATE TABLE reviews (
|
|
396
|
+
id TEXT PRIMARY KEY,
|
|
397
|
+
repo_key TEXT NOT NULL,
|
|
398
|
+
repo_root TEXT NOT NULL,
|
|
399
|
+
repo_remote TEXT,
|
|
400
|
+
branch TEXT NOT NULL,
|
|
401
|
+
slug TEXT NOT NULL,
|
|
402
|
+
base TEXT,
|
|
403
|
+
head TEXT,
|
|
404
|
+
head_sha TEXT,
|
|
405
|
+
reviewed_sha TEXT,
|
|
406
|
+
reviewed_at TEXT,
|
|
407
|
+
status TEXT,
|
|
408
|
+
started_at TEXT,
|
|
409
|
+
created_at TEXT NOT NULL,
|
|
410
|
+
updated_at TEXT NOT NULL,
|
|
411
|
+
UNIQUE(repo_key, branch)
|
|
412
|
+
);
|
|
413
|
+
PRAGMA user_version = 1;
|
|
414
|
+
`);
|
|
415
|
+
legacy.run(
|
|
416
|
+
`INSERT INTO reviews
|
|
417
|
+
(id, repo_key, repo_root, branch, slug, status, created_at, updated_at)
|
|
418
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
419
|
+
[
|
|
420
|
+
"11111111-1111-4111-8111-111111111111",
|
|
421
|
+
"path:/repo",
|
|
422
|
+
dir,
|
|
423
|
+
"feature/x",
|
|
424
|
+
"feature-x",
|
|
425
|
+
"reviewing",
|
|
426
|
+
"2026-01-01T00:00:00.000Z",
|
|
427
|
+
"2026-01-01T00:00:00.000Z",
|
|
428
|
+
],
|
|
429
|
+
);
|
|
430
|
+
} finally {
|
|
431
|
+
legacy.close();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const storage = new SQLiteStorageProvider(path);
|
|
435
|
+
try {
|
|
436
|
+
const review = storage.listReviews()[0];
|
|
437
|
+
expect(review?.heartbeatAt).toBeUndefined();
|
|
438
|
+
storage.updateReview(review.id, {
|
|
439
|
+
heartbeatAt: "2026-01-01T00:05:00.000Z",
|
|
440
|
+
});
|
|
441
|
+
expect(storage.getReview(review.id)?.heartbeatAt).toBe(
|
|
442
|
+
"2026-01-01T00:05:00.000Z",
|
|
443
|
+
);
|
|
444
|
+
} finally {
|
|
445
|
+
storage.close();
|
|
446
|
+
rmSync(dir, { recursive: true, force: true });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Markdown mínimo do campo `action` (cliente).
|
|
3
|
-
*
|
|
4
|
-
* Suporta o suficiente para instruções curtas: parágrafos, quebras de linha,
|
|
5
|
-
* listas, blockquote, blocos de código e formatação inline (`code`, **bold**,
|
|
6
|
-
* *itálico*, links). Sem lib externa e sem HTML cru.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export type MarkdownBlock =
|
|
10
|
-
| { type: "paragraph"; text: string }
|
|
11
|
-
| { type: "list"; ordered: boolean; items: string[] }
|
|
12
|
-
| { type: "code"; language: string | null; code: string }
|
|
13
|
-
| { type: "quote"; text: string };
|
|
14
|
-
|
|
15
|
-
export type InlineToken =
|
|
16
|
-
| { type: "text"; text: string }
|
|
17
|
-
| { type: "code"; text: string }
|
|
18
|
-
| { type: "strong"; text: string }
|
|
19
|
-
| { type: "em"; text: string }
|
|
20
|
-
| { type: "link"; text: string; url: string };
|
|
21
|
-
|
|
22
|
-
const FENCE = /^\s*```(\S*)\s*$/;
|
|
23
|
-
const BULLET = /^\s*[-*+]\s+(.*)$/;
|
|
24
|
-
const ORDERED = /^\s*\d+\.\s+(.*)$/;
|
|
25
|
-
const QUOTE = /^\s*>\s?(.*)$/;
|
|
26
|
-
|
|
27
|
-
function isBlockStart(line: string): boolean {
|
|
28
|
-
return (
|
|
29
|
-
FENCE.test(line) || BULLET.test(line) || ORDERED.test(line) || QUOTE.test(line)
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Divide o texto em blocos simples (sem aninhamento profundo). */
|
|
34
|
-
export function parseMarkdown(text: string): MarkdownBlock[] {
|
|
35
|
-
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
36
|
-
const blocks: MarkdownBlock[] = [];
|
|
37
|
-
let index = 0;
|
|
38
|
-
|
|
39
|
-
while (index < lines.length) {
|
|
40
|
-
const line = lines[index];
|
|
41
|
-
if (line.trim().length === 0) {
|
|
42
|
-
index++;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const fence = FENCE.exec(line);
|
|
47
|
-
if (fence) {
|
|
48
|
-
const language = fence[1] || null;
|
|
49
|
-
const code: string[] = [];
|
|
50
|
-
index++;
|
|
51
|
-
while (index < lines.length && !FENCE.test(lines[index])) {
|
|
52
|
-
code.push(lines[index]);
|
|
53
|
-
index++;
|
|
54
|
-
}
|
|
55
|
-
if (index < lines.length) index++;
|
|
56
|
-
blocks.push({ type: "code", language, code: code.join("\n") });
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (BULLET.test(line)) {
|
|
61
|
-
const items: string[] = [];
|
|
62
|
-
while (index < lines.length) {
|
|
63
|
-
const item = BULLET.exec(lines[index]);
|
|
64
|
-
if (!item) break;
|
|
65
|
-
items.push(item[1]);
|
|
66
|
-
index++;
|
|
67
|
-
}
|
|
68
|
-
blocks.push({ type: "list", ordered: false, items });
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (ORDERED.test(line)) {
|
|
73
|
-
const items: string[] = [];
|
|
74
|
-
while (index < lines.length) {
|
|
75
|
-
const item = ORDERED.exec(lines[index]);
|
|
76
|
-
if (!item) break;
|
|
77
|
-
items.push(item[1]);
|
|
78
|
-
index++;
|
|
79
|
-
}
|
|
80
|
-
blocks.push({ type: "list", ordered: true, items });
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (QUOTE.test(line)) {
|
|
85
|
-
const quote: string[] = [];
|
|
86
|
-
while (index < lines.length) {
|
|
87
|
-
const item = QUOTE.exec(lines[index]);
|
|
88
|
-
if (!item) break;
|
|
89
|
-
quote.push(item[1]);
|
|
90
|
-
index++;
|
|
91
|
-
}
|
|
92
|
-
blocks.push({ type: "quote", text: quote.join("\n") });
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const paragraph: string[] = [line];
|
|
97
|
-
index++;
|
|
98
|
-
while (
|
|
99
|
-
index < lines.length &&
|
|
100
|
-
lines[index].trim().length > 0 &&
|
|
101
|
-
!isBlockStart(lines[index])
|
|
102
|
-
) {
|
|
103
|
-
paragraph.push(lines[index]);
|
|
104
|
-
index++;
|
|
105
|
-
}
|
|
106
|
-
blocks.push({ type: "paragraph", text: paragraph.join("\n") });
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return blocks;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Tokeniza formatação inline preservando o texto e a ordem dos marcadores. */
|
|
113
|
-
export function parseInline(text: string): InlineToken[] {
|
|
114
|
-
const tokens: InlineToken[] = [];
|
|
115
|
-
// Ordem importa: code/bold antes de itálico; `_` exige fronteira de palavra.
|
|
116
|
-
const pattern =
|
|
117
|
-
/`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|(?<![\w])_[^_\n]+_(?![\w])|\[[^\]\n]+\]\([^)\s]+\)/g;
|
|
118
|
-
let last = 0;
|
|
119
|
-
for (const match of text.matchAll(pattern)) {
|
|
120
|
-
const start = match.index ?? 0;
|
|
121
|
-
if (start > last) {
|
|
122
|
-
tokens.push({ type: "text", text: text.slice(last, start) });
|
|
123
|
-
}
|
|
124
|
-
const raw = match[0];
|
|
125
|
-
if (raw.startsWith("`")) {
|
|
126
|
-
tokens.push({ type: "code", text: raw.slice(1, -1) });
|
|
127
|
-
} else if (raw.startsWith("**") || raw.startsWith("__")) {
|
|
128
|
-
tokens.push({ type: "strong", text: raw.slice(2, -2) });
|
|
129
|
-
} else if (raw.startsWith("*") || raw.startsWith("_")) {
|
|
130
|
-
tokens.push({ type: "em", text: raw.slice(1, -1) });
|
|
131
|
-
} else {
|
|
132
|
-
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(raw);
|
|
133
|
-
if (link) {
|
|
134
|
-
tokens.push({ type: "link", text: link[1], url: link[2] });
|
|
135
|
-
} else {
|
|
136
|
-
tokens.push({ type: "text", text: raw });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
last = start + raw.length;
|
|
140
|
-
}
|
|
141
|
-
if (last < text.length) {
|
|
142
|
-
tokens.push({ type: "text", text: text.slice(last) });
|
|
143
|
-
}
|
|
144
|
-
return tokens;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** URL segura para links de markdown (evita `javascript:` e afins). */
|
|
148
|
-
export function safeUrl(url: string): string | null {
|
|
149
|
-
const value = url.trim();
|
|
150
|
-
if (/^(https?:|mailto:)/i.test(value)) return value;
|
|
151
|
-
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|