@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.
Files changed (58) hide show
  1. package/README.md +12 -38
  2. package/package.json +1 -1
  3. package/src/cli/commands/status-command.ts +46 -0
  4. package/src/cli/help.ts +1 -0
  5. package/src/cli/index.ts +3 -0
  6. package/src/core/commands/findings.ts +61 -5
  7. package/src/core/commands/status.ts +63 -0
  8. package/src/core/engine/review-scope.ts +26 -8
  9. package/src/core/findings-state.ts +21 -4
  10. package/src/core/github-review.ts +620 -57
  11. package/src/core/orchestrator.ts +98 -8
  12. package/src/core/paths.ts +3 -0
  13. package/src/core/renderers/resume-renderer.ts +151 -0
  14. package/src/core/report-builder.ts +7 -4
  15. package/src/core/run-status.ts +39 -5
  16. package/src/core/storage/file/file-storage.ts +2 -0
  17. package/src/core/storage/sqlite/mappers.ts +2 -0
  18. package/src/core/storage/sqlite/schema.ts +9 -1
  19. package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
  20. package/src/core/storage/types.ts +9 -0
  21. package/src/server/client/App.tsx +26 -9
  22. package/src/server/client/ArtifactsPanel.tsx +12 -5
  23. package/src/server/client/AutoRefresh.tsx +1 -1
  24. package/src/server/client/Filters.tsx +7 -2
  25. package/src/server/client/FindingCard.tsx +14 -10
  26. package/src/server/client/FindingsList.tsx +2 -2
  27. package/src/server/client/MarkdownText.tsx +92 -12
  28. package/src/server/client/ResumePanel.tsx +368 -0
  29. package/src/server/client/RunList.tsx +3 -3
  30. package/src/server/client/SubmitModal.tsx +49 -6
  31. package/src/server/client/SummaryBar.tsx +1 -1
  32. package/src/server/client/icons.tsx +17 -0
  33. package/src/server/client/{constants.ts → utils/constants.ts} +34 -5
  34. package/src/server/client/utils/markdown.ts +319 -0
  35. package/src/server/client/{types.ts → utils/types.ts} +58 -0
  36. package/src/server/index.ts +63 -5
  37. package/src/server/run-repository.ts +9 -0
  38. package/src/skill/example-review.md +22 -14
  39. package/src/skill/hunt.md +31 -17
  40. package/src/skill/verify.md +2 -1
  41. package/src/tests/__snapshots__/baseline.test.ts.snap +32 -30
  42. package/src/tests/baseline.test.ts +3 -0
  43. package/src/tests/code-highlight.test.ts +1 -1
  44. package/src/tests/findings-state.test.ts +50 -2
  45. package/src/tests/github-review.test.ts +409 -60
  46. package/src/tests/markdown.test.ts +61 -1
  47. package/src/tests/review-resume.test.ts +101 -0
  48. package/src/tests/review-scope.test.ts +302 -0
  49. package/src/tests/review-scripts.test.ts +73 -0
  50. package/src/tests/run-status-view.test.ts +94 -0
  51. package/src/tests/run-status.test.ts +53 -0
  52. package/src/tests/server.test.ts +76 -21
  53. package/src/tests/storage.test.ts +63 -0
  54. package/src/server/client/markdown.ts +0 -153
  55. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  56. /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
  57. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
  58. /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
@@ -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
- ok: true,
238
- code: 0,
239
- stderr: "",
240
- stdout: JSON.stringify({
241
- number: 12,
242
- url: "https://github.com/acme/app/pull/12",
243
- headRefOid: "deadbeef",
244
- }),
245
- }
246
- : {
247
- ok: true,
248
- code: 0,
249
- stderr: "",
250
- stdout: JSON.stringify({
251
- id: 501,
252
- html_url: "https://x/501",
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