@payconductor/houndsman 0.3.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 +25 -39
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +8 -3
- package/src/cli/index.ts +5 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +216 -125
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/commands/status.ts +63 -0
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +53 -75
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +271 -134
- package/src/core/paths.ts +26 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +59 -50
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +271 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +162 -0
- package/src/core/storage/sqlite/schema.ts +116 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
- package/src/core/storage/types.ts +152 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +144 -23
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +50 -15
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +181 -0
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +58 -10
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/utils/code-highlight.ts +422 -0
- package/src/server/client/utils/constants.ts +226 -0
- package/src/server/client/utils/json-highlight.ts +55 -0
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +81 -0
- package/src/server/index.ts +154 -16
- package/src/server/run-repository.ts +153 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/example-review.md +22 -14
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +55 -32
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +7 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
- package/src/tests/baseline.test.ts +79 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +56 -5
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +149 -0
- 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 +81 -5
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +86 -23
- package/src/tests/server.test.ts +184 -96
- package/src/tests/storage.test.ts +449 -0
- package/src/server/client/constants.ts +0 -91
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View do status na sidebar — timeout só vale quando há heartbeat de execução.
|
|
3
|
+
* `updatedAt` (triagem/sync) não deve simular uma revisão em andamento.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
describeRunStatus,
|
|
9
|
+
REVIEW_TIMEOUT_MS,
|
|
10
|
+
STATUS_TIMEOUT_MS,
|
|
11
|
+
} from "../server/client/utils/constants";
|
|
12
|
+
import type { RunSummary, Summary } from "../server/client/utils/types";
|
|
13
|
+
|
|
14
|
+
const summary: Summary = {
|
|
15
|
+
total: 0,
|
|
16
|
+
present: 0,
|
|
17
|
+
byDecision: { pending: 0, execute: 0, ignore: 0 },
|
|
18
|
+
byExecState: { pending: 0, in_progress: 0, done: 0, stale: 0 },
|
|
19
|
+
bySeverity: { blocking: 0, attention: 0, suggestion: 0, nit: 0 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const NOW = Date.parse("2026-10-17T12:00:00.000Z");
|
|
23
|
+
const iso = (offsetMs: number): string => new Date(NOW + offsetMs).toISOString();
|
|
24
|
+
|
|
25
|
+
function run(overrides: Partial<RunSummary>): RunSummary {
|
|
26
|
+
return {
|
|
27
|
+
slug: "feature-x",
|
|
28
|
+
branch: "feature/x",
|
|
29
|
+
summary,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("describeRunStatus", () => {
|
|
35
|
+
test("heartbeat recente mantém o status de execução", () => {
|
|
36
|
+
expect(
|
|
37
|
+
describeRunStatus(
|
|
38
|
+
run({ status: "reviewing", heartbeatAt: iso(-60_000) }),
|
|
39
|
+
NOW,
|
|
40
|
+
),
|
|
41
|
+
).toEqual({ kind: "reviewing", label: "Revisando", timeout: false });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("heartbeat antigo vira timeout", () => {
|
|
45
|
+
expect(
|
|
46
|
+
describeRunStatus(
|
|
47
|
+
run({
|
|
48
|
+
status: "reviewing",
|
|
49
|
+
heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
|
|
50
|
+
}),
|
|
51
|
+
NOW,
|
|
52
|
+
),
|
|
53
|
+
).toEqual({ kind: "timeout", label: "Timeout na revisão", timeout: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("updatedAt recente não mascara heartbeat antigo", () => {
|
|
57
|
+
expect(
|
|
58
|
+
describeRunStatus(
|
|
59
|
+
run({
|
|
60
|
+
status: "reviewing",
|
|
61
|
+
updatedAt: iso(-1_000),
|
|
62
|
+
heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
|
|
63
|
+
}),
|
|
64
|
+
NOW,
|
|
65
|
+
)?.timeout,
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("sem heartbeat não inventa revisão em andamento", () => {
|
|
70
|
+
expect(
|
|
71
|
+
describeRunStatus(run({ status: "reviewing", updatedAt: iso(-1_000) }), NOW),
|
|
72
|
+
).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("status terminal aparece normalmente sem heartbeat", () => {
|
|
76
|
+
expect(describeRunStatus(run({ status: "review_done" }), NOW)).toEqual({
|
|
77
|
+
kind: "review_done",
|
|
78
|
+
label: "Revisão pronta",
|
|
79
|
+
timeout: false,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("correção também usa heartbeat para timeout", () => {
|
|
84
|
+
expect(
|
|
85
|
+
describeRunStatus(
|
|
86
|
+
run({
|
|
87
|
+
status: "fixing",
|
|
88
|
+
heartbeatAt: iso(-STATUS_TIMEOUT_MS - 1_000),
|
|
89
|
+
}),
|
|
90
|
+
NOW,
|
|
91
|
+
),
|
|
92
|
+
).toEqual({ kind: "timeout", label: "Timeout na correção", timeout: true });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -7,8 +7,11 @@ import {
|
|
|
7
7
|
pingFindingsStatus,
|
|
8
8
|
readRunStatus,
|
|
9
9
|
statusAfterFindingsChange,
|
|
10
|
+
touchRunStatus,
|
|
10
11
|
writeRunStatus,
|
|
11
12
|
} from "../core/run-status";
|
|
13
|
+
import { buildRepoIdentity } from "../core/storage/repo-identity";
|
|
14
|
+
import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
|
|
12
15
|
|
|
13
16
|
function findingState(partial: Partial<FindingState>): FindingState {
|
|
14
17
|
return {
|
|
@@ -45,53 +48,113 @@ function stateOf(findings: FindingState[]): FindingsStateFile {
|
|
|
45
48
|
};
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
/**
|
|
49
|
-
function
|
|
51
|
+
/** Banco temporário isolado + review da branch padrão. */
|
|
52
|
+
function withStorage(
|
|
53
|
+
fn: (storage: SQLiteStorageProvider, reviewId: string) => void,
|
|
54
|
+
): void {
|
|
50
55
|
const dir = mkdtempSync(join(tmpdir(), "houndsman-run-status-"));
|
|
56
|
+
const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
|
|
51
57
|
try {
|
|
52
|
-
|
|
58
|
+
const review = storage.getOrCreateReview({
|
|
59
|
+
repo: buildRepoIdentity(null, dir),
|
|
60
|
+
branch: "feature/x",
|
|
61
|
+
slug: "feature-x",
|
|
62
|
+
});
|
|
63
|
+
fn(storage, review.id);
|
|
53
64
|
} finally {
|
|
65
|
+
storage.close();
|
|
54
66
|
rmSync(dir, { recursive: true, force: true });
|
|
55
67
|
}
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
describe("run-status —
|
|
70
|
+
describe("run-status — storage", () => {
|
|
59
71
|
test("grava, lê e preserva/restaura o startedAt", () => {
|
|
60
|
-
|
|
61
|
-
writeRunStatus(
|
|
62
|
-
branch: "feature/x",
|
|
72
|
+
withStorage((storage, reviewId) => {
|
|
73
|
+
writeRunStatus(storage, reviewId, {
|
|
63
74
|
status: "reviewing",
|
|
64
75
|
resetStartedAt: true,
|
|
65
76
|
now: "2026-01-01T00:00:00.000Z",
|
|
66
77
|
});
|
|
67
|
-
const first = readRunStatus(
|
|
78
|
+
const first = readRunStatus(storage, reviewId);
|
|
68
79
|
expect(first?.status).toBe("reviewing");
|
|
69
80
|
expect(first?.slug).toBe("feature-x");
|
|
70
81
|
expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
82
|
+
expect(first?.heartbeatAt).toBe("2026-01-01T00:00:00.000Z");
|
|
71
83
|
|
|
72
|
-
writeRunStatus(
|
|
73
|
-
branch: "feature/x",
|
|
84
|
+
writeRunStatus(storage, reviewId, {
|
|
74
85
|
status: "review_done",
|
|
75
86
|
now: "2026-01-01T00:10:00.000Z",
|
|
76
87
|
});
|
|
77
|
-
const second = readRunStatus(
|
|
88
|
+
const second = readRunStatus(storage, reviewId);
|
|
78
89
|
expect(second?.status).toBe("review_done");
|
|
79
90
|
expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
91
|
+
expect(second?.heartbeatAt).toBeUndefined();
|
|
80
92
|
expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
|
|
81
93
|
|
|
82
|
-
writeRunStatus(
|
|
83
|
-
branch: "feature/x",
|
|
94
|
+
writeRunStatus(storage, reviewId, {
|
|
84
95
|
status: "reviewing",
|
|
85
96
|
resetStartedAt: true,
|
|
86
97
|
now: "2026-01-01T00:20:00.000Z",
|
|
87
98
|
});
|
|
88
|
-
expect(readRunStatus(
|
|
99
|
+
expect(readRunStatus(storage, reviewId)?.startedAt).toBe(
|
|
100
|
+
"2026-01-01T00:20:00.000Z",
|
|
101
|
+
);
|
|
102
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
|
|
103
|
+
"2026-01-01T00:20:00.000Z",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("touch renova o heartbeat apenas em execução ativa", () => {
|
|
109
|
+
withStorage((storage, reviewId) => {
|
|
110
|
+
writeRunStatus(storage, reviewId, {
|
|
111
|
+
status: "reviewing",
|
|
112
|
+
resetStartedAt: true,
|
|
113
|
+
now: "2026-01-01T00:00:00.000Z",
|
|
114
|
+
});
|
|
115
|
+
touchRunStatus(storage, reviewId, "2026-01-01T00:05:00.000Z");
|
|
116
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
|
|
117
|
+
"2026-01-01T00:05:00.000Z",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
writeRunStatus(storage, reviewId, {
|
|
121
|
+
status: "review_done",
|
|
122
|
+
now: "2026-01-01T00:10:00.000Z",
|
|
123
|
+
});
|
|
124
|
+
touchRunStatus(storage, reviewId, "2026-01-01T00:15:00.000Z");
|
|
125
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("triagem em reviewing não renova o heartbeat; fix em andamento sim", () => {
|
|
130
|
+
withStorage((storage, reviewId) => {
|
|
131
|
+
writeRunStatus(storage, reviewId, {
|
|
132
|
+
status: "reviewing",
|
|
133
|
+
resetStartedAt: true,
|
|
134
|
+
now: "2026-01-01T00:00:00.000Z",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Triagem (decision/comment) mantém reviewing — não é execução.
|
|
138
|
+
pingFindingsStatus(storage, reviewId, stateOf([]));
|
|
139
|
+
expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
|
|
140
|
+
"2026-01-01T00:00:00.000Z",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Correção em andamento renova o heartbeat.
|
|
144
|
+
pingFindingsStatus(
|
|
145
|
+
storage,
|
|
146
|
+
reviewId,
|
|
147
|
+
stateOf([findingState({ execState: "in_progress" })]),
|
|
148
|
+
);
|
|
149
|
+
const fixed = readRunStatus(storage, reviewId);
|
|
150
|
+
expect(fixed?.status).toBe("fixing");
|
|
151
|
+
expect(fixed?.heartbeatAt).not.toBe("2026-01-01T00:00:00.000Z");
|
|
89
152
|
});
|
|
90
153
|
});
|
|
91
154
|
|
|
92
|
-
test("readRunStatus devolve null
|
|
93
|
-
|
|
94
|
-
expect(readRunStatus(
|
|
155
|
+
test("readRunStatus devolve null para review inexistente", () => {
|
|
156
|
+
withStorage((storage) => {
|
|
157
|
+
expect(readRunStatus(storage, "missing")).toBeNull();
|
|
95
158
|
});
|
|
96
159
|
});
|
|
97
160
|
});
|
|
@@ -127,7 +190,7 @@ describe("statusAfterFindingsChange", () => {
|
|
|
127
190
|
expect(statusAfterFindingsChange("review_done", state)).toBe("fix_done");
|
|
128
191
|
});
|
|
129
192
|
|
|
130
|
-
test("sem
|
|
193
|
+
test("sem review e sem fix → fallback review_done", () => {
|
|
131
194
|
const state = stateOf([
|
|
132
195
|
findingState({ decision: "pending", execState: "pending" }),
|
|
133
196
|
]);
|
|
@@ -136,12 +199,12 @@ describe("statusAfterFindingsChange", () => {
|
|
|
136
199
|
});
|
|
137
200
|
|
|
138
201
|
describe("pingFindingsStatus", () => {
|
|
139
|
-
test("deriva do estado e grava o
|
|
140
|
-
|
|
202
|
+
test("deriva do estado e grava o status na review", () => {
|
|
203
|
+
withStorage((storage, reviewId) => {
|
|
141
204
|
const state = stateOf([findingState({ execState: "in_progress" })]);
|
|
142
|
-
const
|
|
143
|
-
expect(
|
|
144
|
-
expect(readRunStatus(
|
|
205
|
+
const review = pingFindingsStatus(storage, reviewId, state);
|
|
206
|
+
expect(review.status).toBe("fixing");
|
|
207
|
+
expect(readRunStatus(storage, reviewId)?.status).toBe("fixing");
|
|
145
208
|
});
|
|
146
209
|
});
|
|
147
210
|
});
|
package/src/tests/server.test.ts
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { HoundsmanConfig } from "../core/config";
|
|
6
|
+
import { buildStateFromFindings, fileReader } from "../core/findings-state";
|
|
7
|
+
import { dedupeFindings, parseWatchdogReport } from "../core/report-builder";
|
|
8
|
+
import { buildRepoIdentity } from "../core/storage/repo-identity";
|
|
9
|
+
import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
|
|
10
|
+
import type { RepoIdentity } from "../core/storage/types";
|
|
6
11
|
import { createRequestHandler, startServer } from "../server";
|
|
7
|
-
import {
|
|
12
|
+
import { isSlug } from "../server/artifacts";
|
|
8
13
|
import { fakeHoundsmanConfig } from "./fixtures/fake-project";
|
|
9
14
|
|
|
10
|
-
/**
|
|
15
|
+
/** Ambiente fake: banco temporário + review `main` com um finding. */
|
|
11
16
|
function makeEnv(): {
|
|
12
|
-
|
|
17
|
+
storage: SQLiteStorageProvider;
|
|
18
|
+
repo: RepoIdentity;
|
|
19
|
+
reviewId: string;
|
|
13
20
|
config: HoundsmanConfig;
|
|
14
21
|
cleanup: () => void;
|
|
15
22
|
} {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
|
|
23
|
-
);
|
|
24
|
-
writeFileSync(join(outDir, "review.md"), "# Review\n");
|
|
25
|
-
writeFileSync(
|
|
26
|
-
join(outDir, "findings", "alpha.json"),
|
|
27
|
-
JSON.stringify({
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-server-"));
|
|
24
|
+
const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
|
|
25
|
+
const repo = buildRepoIdentity(null, dir);
|
|
26
|
+
const review = storage.getOrCreateReview({ repo, branch: "main", slug: "main" });
|
|
27
|
+
const report = parseWatchdogReport(
|
|
28
|
+
{
|
|
28
29
|
watchdogId: "alpha",
|
|
29
30
|
reviewedFiles: ["src/alpha/f.ts"],
|
|
30
31
|
skipped: [],
|
|
@@ -38,58 +39,64 @@ function makeEnv(): {
|
|
|
38
39
|
action: "tipar",
|
|
39
40
|
},
|
|
40
41
|
],
|
|
41
|
-
}
|
|
42
|
+
},
|
|
43
|
+
"alpha.json",
|
|
42
44
|
);
|
|
45
|
+
if (report) {
|
|
46
|
+
const state = buildStateFromFindings(dedupeFindings([report]), {
|
|
47
|
+
branch: "main",
|
|
48
|
+
readFile: fileReader(process.cwd()),
|
|
49
|
+
});
|
|
50
|
+
// O arquivo fake não existe no cwd → sem âncora. Fixa a linha para o
|
|
51
|
+
// cenário de comentário inline no teste de submit.
|
|
52
|
+
for (const finding of Object.values(state.findings)) finding.line = 2;
|
|
53
|
+
storage.writeFindings(review.id, state);
|
|
54
|
+
}
|
|
55
|
+
storage.writeArtifact(review.id, {
|
|
56
|
+
name: "manifest.json",
|
|
57
|
+
kind: "manifest",
|
|
58
|
+
content: JSON.stringify({ branch: "main" }),
|
|
59
|
+
});
|
|
60
|
+
storage.writeArtifact(review.id, {
|
|
61
|
+
name: "classification.json",
|
|
62
|
+
kind: "classification",
|
|
63
|
+
content: JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
|
|
64
|
+
});
|
|
65
|
+
storage.writeArtifact(review.id, {
|
|
66
|
+
name: "review.md",
|
|
67
|
+
kind: "review",
|
|
68
|
+
content: "# Review\n",
|
|
69
|
+
});
|
|
70
|
+
storage.writeArtifact(review.id, {
|
|
71
|
+
name: "briefs/alpha.md",
|
|
72
|
+
kind: "brief",
|
|
73
|
+
content: "# Brief alpha\n",
|
|
74
|
+
});
|
|
43
75
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
76
|
+
storage,
|
|
77
|
+
repo,
|
|
78
|
+
reviewId: review.id,
|
|
79
|
+
config: fakeHoundsmanConfig,
|
|
80
|
+
cleanup: () => {
|
|
81
|
+
storage.close();
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
},
|
|
47
84
|
};
|
|
48
85
|
}
|
|
49
86
|
|
|
50
|
-
/** Semeia um `state.json` com um finding inline (tem linha) na run. */
|
|
51
|
-
function seedState(artifactDir: string, branch: string): void {
|
|
52
|
-
writeFileSync(
|
|
53
|
-
join(artifactDir, branch, "state.json"),
|
|
54
|
-
JSON.stringify({
|
|
55
|
-
version: 2,
|
|
56
|
-
branch,
|
|
57
|
-
slug: branch,
|
|
58
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
59
|
-
order: ["f1"],
|
|
60
|
-
findings: {
|
|
61
|
-
f1: {
|
|
62
|
-
id: "f1",
|
|
63
|
-
fingerprint: "fp1",
|
|
64
|
-
watchdogIds: ["alpha"],
|
|
65
|
-
rule: "Nunca usar any",
|
|
66
|
-
severity: "blocking",
|
|
67
|
-
file: "src/alpha/f.ts",
|
|
68
|
-
line: 2,
|
|
69
|
-
message: "any detectado",
|
|
70
|
-
action: "tipar",
|
|
71
|
-
anchor: null,
|
|
72
|
-
count: 1,
|
|
73
|
-
present: true,
|
|
74
|
-
decision: "pending",
|
|
75
|
-
execState: "pending",
|
|
76
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
77
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
78
|
-
history: [],
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
}),
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
87
|
const get = (handler: (r: Request) => Promise<Response>, path: string) =>
|
|
86
88
|
handler(new Request(`http://localhost${path}`));
|
|
87
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
|
+
|
|
88
95
|
describe("server API", () => {
|
|
89
96
|
test("GET /api/runs lista as runs com resumo", async () => {
|
|
90
97
|
const env = makeEnv();
|
|
91
98
|
try {
|
|
92
|
-
const handler = createRequestHandler(env.config);
|
|
99
|
+
const handler = createRequestHandler(env.config, env);
|
|
93
100
|
const res = await get(handler, "/api/runs");
|
|
94
101
|
expect(res.status).toBe(200);
|
|
95
102
|
const body = (await res.json()) as {
|
|
@@ -106,7 +113,7 @@ describe("server API", () => {
|
|
|
106
113
|
test("GET findings reconstrói do artefato e PATCH atualiza o estado", async () => {
|
|
107
114
|
const env = makeEnv();
|
|
108
115
|
try {
|
|
109
|
-
const handler = createRequestHandler(env.config);
|
|
116
|
+
const handler = createRequestHandler(env.config, env);
|
|
110
117
|
const list = await get(handler, "/api/runs/main/findings?all=1");
|
|
111
118
|
const body = (await list.json()) as {
|
|
112
119
|
findings: { id: string; decision: string }[];
|
|
@@ -148,7 +155,7 @@ describe("server API", () => {
|
|
|
148
155
|
test("PATCH expõe o run status em /api/runs", async () => {
|
|
149
156
|
const env = makeEnv();
|
|
150
157
|
try {
|
|
151
|
-
const handler = createRequestHandler(env.config);
|
|
158
|
+
const handler = createRequestHandler(env.config, env);
|
|
152
159
|
const before = (await (await get(handler, "/api/runs")).json()) as {
|
|
153
160
|
runs: { status: string | null }[];
|
|
154
161
|
};
|
|
@@ -178,7 +185,7 @@ describe("server API", () => {
|
|
|
178
185
|
test("POST sync devolve o resultado da reancoragem", async () => {
|
|
179
186
|
const env = makeEnv();
|
|
180
187
|
try {
|
|
181
|
-
const handler = createRequestHandler(env.config);
|
|
188
|
+
const handler = createRequestHandler(env.config, env);
|
|
182
189
|
await get(handler, "/api/runs/main/findings?all=1");
|
|
183
190
|
const res = await handler(
|
|
184
191
|
new Request("http://localhost/api/runs/main/sync", { method: "POST" }),
|
|
@@ -195,6 +202,8 @@ describe("server API", () => {
|
|
|
195
202
|
const env = makeEnv();
|
|
196
203
|
try {
|
|
197
204
|
const handler = createRequestHandler(env.config, {
|
|
205
|
+
storage: env.storage,
|
|
206
|
+
repo: env.repo,
|
|
198
207
|
ghRunner: () => ({
|
|
199
208
|
ok: true,
|
|
200
209
|
code: 0,
|
|
@@ -224,29 +233,80 @@ describe("server API", () => {
|
|
|
224
233
|
test("POST submit posta comentários e grava o vínculo no estado", async () => {
|
|
225
234
|
const env = makeEnv();
|
|
226
235
|
try {
|
|
227
|
-
seedState(env.artifactDir, "main");
|
|
228
236
|
const handler = createRequestHandler(env.config, {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
: {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
237
|
+
storage: env.storage,
|
|
238
|
+
repo: env.repo,
|
|
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
|
+
},
|
|
250
310
|
});
|
|
251
311
|
const res = await handler(
|
|
252
312
|
new Request("http://localhost/api/runs/main/submit", {
|
|
@@ -273,10 +333,35 @@ describe("server API", () => {
|
|
|
273
333
|
}
|
|
274
334
|
});
|
|
275
335
|
|
|
336
|
+
test("artifacts lista todos os artefatos da run (inclui briefs)", async () => {
|
|
337
|
+
const env = makeEnv();
|
|
338
|
+
try {
|
|
339
|
+
const handler = createRequestHandler(env.config, env);
|
|
340
|
+
const res = await get(handler, "/api/runs/main/artifacts");
|
|
341
|
+
expect(res.status).toBe(200);
|
|
342
|
+
const body = (await res.json()) as {
|
|
343
|
+
artifacts: { name: string; kind: string }[];
|
|
344
|
+
};
|
|
345
|
+
expect(body.artifacts.map((item) => item.name)).toEqual(
|
|
346
|
+
expect.arrayContaining([
|
|
347
|
+
"manifest.json",
|
|
348
|
+
"classification.json",
|
|
349
|
+
"review.md",
|
|
350
|
+
"briefs/alpha.md",
|
|
351
|
+
]),
|
|
352
|
+
);
|
|
353
|
+
expect(
|
|
354
|
+
body.artifacts.find((item) => item.name === "briefs/alpha.md")?.kind,
|
|
355
|
+
).toBe("brief");
|
|
356
|
+
} finally {
|
|
357
|
+
env.cleanup();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
276
361
|
test("artifacts serve arquivos da run e barra o resto", async () => {
|
|
277
362
|
const env = makeEnv();
|
|
278
363
|
try {
|
|
279
|
-
const handler = createRequestHandler(env.config);
|
|
364
|
+
const handler = createRequestHandler(env.config, env);
|
|
280
365
|
const ok = await get(handler, "/api/runs/main/artifacts/manifest.json");
|
|
281
366
|
expect(ok.status).toBe(200);
|
|
282
367
|
expect(await ok.json()).toEqual({ branch: "main" });
|
|
@@ -285,6 +370,10 @@ describe("server API", () => {
|
|
|
285
370
|
expect(review.status).toBe(200);
|
|
286
371
|
expect(await review.text()).toContain("# Review");
|
|
287
372
|
|
|
373
|
+
const brief = await get(handler, "/api/runs/main/artifacts/briefs/alpha.md");
|
|
374
|
+
expect(brief.status).toBe(200);
|
|
375
|
+
expect(await brief.text()).toContain("# Brief alpha");
|
|
376
|
+
|
|
288
377
|
const denied = await get(handler, "/api/runs/main/artifacts/evil.txt");
|
|
289
378
|
expect(denied.status).toBe(404);
|
|
290
379
|
|
|
@@ -295,23 +384,17 @@ describe("server API", () => {
|
|
|
295
384
|
}
|
|
296
385
|
});
|
|
297
386
|
|
|
298
|
-
test("
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
expect(readRunFile(dir as string, "../../../etc/passwd")).toBeNull();
|
|
304
|
-
expect(readRunFile(dir as string, "manifest.json")).toContain("main");
|
|
305
|
-
expect(runDirForSlug(env.artifactDir, "../main")).toBeNull();
|
|
306
|
-
} finally {
|
|
307
|
-
env.cleanup();
|
|
308
|
-
}
|
|
387
|
+
test("valida o slug usado nas rotas", () => {
|
|
388
|
+
expect(isSlug("main")).toBe(true);
|
|
389
|
+
expect(isSlug("feature-x")).toBe(true);
|
|
390
|
+
expect(isSlug("../main")).toBe(false);
|
|
391
|
+
expect(isSlug("a/b")).toBe(false);
|
|
309
392
|
});
|
|
310
393
|
|
|
311
394
|
test("GET / serve o shell e /assets/app.js empacota o cliente", async () => {
|
|
312
395
|
const env = makeEnv();
|
|
313
396
|
try {
|
|
314
|
-
const handler = createRequestHandler(env.config);
|
|
397
|
+
const handler = createRequestHandler(env.config, env);
|
|
315
398
|
const shell = await get(handler, "/");
|
|
316
399
|
expect(await shell.text()).toContain('id="app"');
|
|
317
400
|
|
|
@@ -328,7 +411,7 @@ describe("server API", () => {
|
|
|
328
411
|
test("serve sem cache (no-store) e busta o bundle com um build id", async () => {
|
|
329
412
|
const env = makeEnv();
|
|
330
413
|
try {
|
|
331
|
-
const handler = createRequestHandler(env.config);
|
|
414
|
+
const handler = createRequestHandler(env.config, env);
|
|
332
415
|
const shell = await get(handler, "/");
|
|
333
416
|
expect(shell.headers.get("cache-control")).toContain("no-store");
|
|
334
417
|
expect(await shell.text()).toContain("/assets/app.js?v=");
|
|
@@ -346,7 +429,12 @@ describe("startServer", () => {
|
|
|
346
429
|
const env = makeEnv();
|
|
347
430
|
let server: ReturnType<typeof startServer>["server"] | undefined;
|
|
348
431
|
try {
|
|
349
|
-
const started = startServer({
|
|
432
|
+
const started = startServer({
|
|
433
|
+
config: env.config,
|
|
434
|
+
port: 0,
|
|
435
|
+
storage: env.storage,
|
|
436
|
+
repo: env.repo,
|
|
437
|
+
});
|
|
350
438
|
server = started.server;
|
|
351
439
|
const res = await fetch(`${started.url}/api/health`);
|
|
352
440
|
expect(res.status).toBe(200);
|