@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
|
@@ -19,9 +19,12 @@ import {
|
|
|
19
19
|
type GhRunner,
|
|
20
20
|
type PrTarget,
|
|
21
21
|
renderGeneralBody,
|
|
22
|
+
renderReviewBody,
|
|
23
|
+
resolveFindingThreads,
|
|
22
24
|
resolvePrTarget,
|
|
23
25
|
selectSubmitCandidates,
|
|
24
26
|
submitFindings,
|
|
27
|
+
syncResolvedThreads,
|
|
25
28
|
} from "../core/github-review";
|
|
26
29
|
import type { Severity } from "../core/types";
|
|
27
30
|
|
|
@@ -77,15 +80,6 @@ function stateWith(findings: FindingState[]): FindingsStateFile {
|
|
|
77
80
|
|
|
78
81
|
const TARGET: PrTarget = { number: 7, repo: "acme/app", headSha: "abc" };
|
|
79
82
|
|
|
80
|
-
function okComment(id: number): GhResult {
|
|
81
|
-
return {
|
|
82
|
-
ok: true,
|
|
83
|
-
code: 0,
|
|
84
|
-
stderr: "",
|
|
85
|
-
stdout: JSON.stringify({ id, html_url: `https://x/${id}` }),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
83
|
/** Runner fake: o handler decide a resposta a partir dos args (os chamados ficam registrados). */
|
|
90
84
|
function fakeRunner(handler: (args: string[]) => GhResult): {
|
|
91
85
|
runner: GhRunner;
|
|
@@ -99,15 +93,177 @@ function fakeRunner(handler: (args: string[]) => GhResult): {
|
|
|
99
93
|
return { runner, calls };
|
|
100
94
|
}
|
|
101
95
|
|
|
102
|
-
/**
|
|
103
|
-
function
|
|
96
|
+
/** Resposta OK do `gh` com um JSON. */
|
|
97
|
+
function okJson(payload: unknown): GhResult {
|
|
98
|
+
return { ok: true, code: 0, stderr: "", stdout: JSON.stringify(payload) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type ReviewRunnerOptions = {
|
|
102
|
+
/** Revisão PENDING já existente. */
|
|
103
|
+
pending?: { id: string; databaseId: number };
|
|
104
|
+
/** Erro GraphQL ao consultar/criar a revisão. */
|
|
105
|
+
lookupError?: string;
|
|
106
|
+
/** Erro ao adicionar a thread da linha. */
|
|
107
|
+
threadError?: string;
|
|
108
|
+
/** Erro ao finalizar a revisão. */
|
|
109
|
+
submitError?: string;
|
|
110
|
+
/** Erro no comentário de arquivo (REST). */
|
|
111
|
+
fileError?: boolean;
|
|
112
|
+
lineId?: number;
|
|
113
|
+
fileId?: number;
|
|
114
|
+
generalId?: number;
|
|
115
|
+
/** Threads do PR (comentário → conversa). */
|
|
116
|
+
threads?: { id: string; commentId: number; isResolved?: boolean }[];
|
|
117
|
+
/** Erro GraphQL ao listar/resolver threads. */
|
|
118
|
+
threadsError?: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Runner fake do `gh`: responde às queries/mutations GraphQL da revisão e aos
|
|
123
|
+
* endpoints REST de fallback. Os chamados ficam registrados.
|
|
124
|
+
*/
|
|
125
|
+
function reviewRunner(options: ReviewRunnerOptions = {}): {
|
|
104
126
|
runner: GhRunner;
|
|
105
127
|
calls: string[][];
|
|
106
128
|
} {
|
|
107
129
|
return fakeRunner((args) => {
|
|
108
|
-
if (args[
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
if (args[0] === "api" && args[1] === "graphql") {
|
|
131
|
+
const query =
|
|
132
|
+
args.find((arg) => arg.startsWith("query="))?.slice("query=".length) ??
|
|
133
|
+
"";
|
|
134
|
+
if (query.includes("states: [PENDING]")) {
|
|
135
|
+
if (options.lookupError) {
|
|
136
|
+
return okJson({ errors: [{ message: options.lookupError }] });
|
|
137
|
+
}
|
|
138
|
+
return okJson({
|
|
139
|
+
data: {
|
|
140
|
+
repository: {
|
|
141
|
+
pullRequest: {
|
|
142
|
+
id: "PR_1",
|
|
143
|
+
reviews: {
|
|
144
|
+
nodes: options.pending
|
|
145
|
+
? [
|
|
146
|
+
{
|
|
147
|
+
id: options.pending.id,
|
|
148
|
+
databaseId:
|
|
149
|
+
options.pending.databaseId,
|
|
150
|
+
resourcePath: `/acme/app/pull/7#pullrequestreview-${options.pending.databaseId}`,
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
: [],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (query.includes("addPullRequestReview(input")) {
|
|
161
|
+
return okJson({
|
|
162
|
+
data: {
|
|
163
|
+
addPullRequestReview: {
|
|
164
|
+
pullRequestReview: {
|
|
165
|
+
id: "REV_NEW",
|
|
166
|
+
databaseId: 11,
|
|
167
|
+
resourcePath: "/acme/app/pull/7#pullrequestreview-11",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (query.includes("addPullRequestReviewThread")) {
|
|
174
|
+
if (options.threadError) {
|
|
175
|
+
return okJson({ errors: [{ message: options.threadError }] });
|
|
176
|
+
}
|
|
177
|
+
const lineId = options.lineId ?? 700;
|
|
178
|
+
return okJson({
|
|
179
|
+
data: {
|
|
180
|
+
addPullRequestReviewThread: {
|
|
181
|
+
thread: {
|
|
182
|
+
id: `THREAD_${lineId}`,
|
|
183
|
+
comments: {
|
|
184
|
+
nodes: [
|
|
185
|
+
{
|
|
186
|
+
databaseId: lineId,
|
|
187
|
+
resourcePath: `/acme/app/pull/7#discussion_r${lineId}`,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (query.includes("reviewThreads")) {
|
|
197
|
+
if (options.threadsError) {
|
|
198
|
+
return okJson({ errors: [{ message: options.threadsError }] });
|
|
199
|
+
}
|
|
200
|
+
return okJson({
|
|
201
|
+
data: {
|
|
202
|
+
repository: {
|
|
203
|
+
pullRequest: {
|
|
204
|
+
reviewThreads: {
|
|
205
|
+
nodes: (options.threads ?? []).map((thread) => ({
|
|
206
|
+
id: thread.id,
|
|
207
|
+
isResolved: thread.isResolved ?? false,
|
|
208
|
+
comments: {
|
|
209
|
+
nodes: [{ databaseId: thread.commentId }],
|
|
210
|
+
},
|
|
211
|
+
})),
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (query.includes("resolveReviewThread")) {
|
|
219
|
+
if (options.threadsError) {
|
|
220
|
+
return okJson({ errors: [{ message: options.threadsError }] });
|
|
221
|
+
}
|
|
222
|
+
return okJson({
|
|
223
|
+
data: {
|
|
224
|
+
resolveReviewThread: {
|
|
225
|
+
thread: { id: "THREAD", isResolved: true },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (query.includes("submitPullRequestReview")) {
|
|
231
|
+
if (options.submitError) {
|
|
232
|
+
return okJson({ errors: [{ message: options.submitError }] });
|
|
233
|
+
}
|
|
234
|
+
return okJson({
|
|
235
|
+
data: {
|
|
236
|
+
submitPullRequestReview: {
|
|
237
|
+
pullRequestReview: {
|
|
238
|
+
id: "REV_NEW",
|
|
239
|
+
databaseId: 11,
|
|
240
|
+
state: "COMMENTED",
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return okJson({ errors: [{ message: "mutation desconhecida" }] });
|
|
247
|
+
}
|
|
248
|
+
if (args[1]?.includes("/issues/")) {
|
|
249
|
+
const generalId = options.generalId ?? 900;
|
|
250
|
+
return okJson({ id: generalId, html_url: `https://x/${generalId}` });
|
|
251
|
+
}
|
|
252
|
+
if (args[0] === "pr" && args[1] === "view") {
|
|
253
|
+
return okJson({
|
|
254
|
+
number: 7,
|
|
255
|
+
url: "https://github.com/acme/app/pull/7",
|
|
256
|
+
headRefOid: "abc",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (args.includes("subject_type=file")) {
|
|
260
|
+
if (options.fileError) {
|
|
261
|
+
return { ok: false, code: 422, stderr: "fora do diff", stdout: "" };
|
|
262
|
+
}
|
|
263
|
+
const fileId = options.fileId ?? 800;
|
|
264
|
+
return okJson({ id: fileId, html_url: `https://x/${fileId}` });
|
|
265
|
+
}
|
|
266
|
+
return { ok: false, code: 404, stdout: "", stderr: "chamada não esperada" };
|
|
111
267
|
});
|
|
112
268
|
}
|
|
113
269
|
|
|
@@ -189,6 +345,19 @@ describe("corpo dos comentários", () => {
|
|
|
189
345
|
expect(body).toContain("Regra X");
|
|
190
346
|
expect(body).toContain("src/a.ts");
|
|
191
347
|
});
|
|
348
|
+
|
|
349
|
+
test("renderReviewBody resume as severidades enviadas", () => {
|
|
350
|
+
const candidates = selectSubmitCandidates(
|
|
351
|
+
stateWith([
|
|
352
|
+
finding({ id: "a", severity: "blocking" }),
|
|
353
|
+
finding({ id: "b", severity: "nit" }),
|
|
354
|
+
]),
|
|
355
|
+
);
|
|
356
|
+
const body = renderReviewBody(candidates);
|
|
357
|
+
expect(body).toContain("2 achado");
|
|
358
|
+
expect(body).toContain("blocking");
|
|
359
|
+
expect(body).toContain("nit");
|
|
360
|
+
});
|
|
192
361
|
});
|
|
193
362
|
|
|
194
363
|
describe("resolvePrTarget", () => {
|
|
@@ -229,66 +398,121 @@ describe("resolvePrTarget", () => {
|
|
|
229
398
|
});
|
|
230
399
|
|
|
231
400
|
describe("submitFindings", () => {
|
|
232
|
-
test("
|
|
233
|
-
const state = stateWith([finding({ id: "a"
|
|
234
|
-
const { runner } =
|
|
401
|
+
test("cria a revisão e finaliza com COMMENT", () => {
|
|
402
|
+
const state = stateWith([finding({ id: "a", severity: "attention" })]);
|
|
403
|
+
const { runner, calls } = reviewRunner();
|
|
235
404
|
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
236
405
|
|
|
237
|
-
expect(outcome.posted).
|
|
238
|
-
|
|
239
|
-
|
|
406
|
+
expect(outcome.posted).toEqual([
|
|
407
|
+
{
|
|
408
|
+
id: "a",
|
|
409
|
+
kind: "line",
|
|
410
|
+
commentId: 700,
|
|
411
|
+
url: "https://github.com/acme/app/pull/7#discussion_r700",
|
|
412
|
+
},
|
|
413
|
+
]);
|
|
414
|
+
expect(outcome.review).toMatchObject({
|
|
415
|
+
id: 11,
|
|
416
|
+
created: true,
|
|
417
|
+
finalized: true,
|
|
418
|
+
event: "COMMENT",
|
|
419
|
+
});
|
|
240
420
|
expect(state.findings.a.github).toMatchObject({
|
|
241
|
-
commentId:
|
|
421
|
+
commentId: 700,
|
|
242
422
|
kind: "line",
|
|
243
423
|
pr: 7,
|
|
424
|
+
threadId: "THREAD_700",
|
|
244
425
|
});
|
|
245
|
-
expect(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
426
|
+
expect(calls.filter((args) => args[1] === "graphql")).toHaveLength(4);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("cria a revisão e finaliza com REQUEST_CHANGES quando há blocking", () => {
|
|
430
|
+
const state = stateWith([finding({ id: "a", severity: "blocking" })]);
|
|
431
|
+
const { runner } = reviewRunner();
|
|
432
|
+
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
433
|
+
|
|
434
|
+
expect(outcome.review?.event).toBe("REQUEST_CHANGES");
|
|
435
|
+
expect(outcome.review?.finalized).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("adiciona à revisão pendente existente sem finalizar", () => {
|
|
439
|
+
const state = stateWith([finding({ id: "a" })]);
|
|
440
|
+
const { runner, calls } = reviewRunner({
|
|
441
|
+
pending: { id: "REV_OLD", databaseId: 42 },
|
|
442
|
+
});
|
|
443
|
+
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
444
|
+
|
|
445
|
+
expect(outcome.review).toMatchObject({
|
|
446
|
+
id: 42,
|
|
447
|
+
created: false,
|
|
448
|
+
finalized: false,
|
|
249
449
|
});
|
|
450
|
+
expect(outcome.posted[0].kind).toBe("line");
|
|
451
|
+
expect(state.findings.a.github?.commentId).toBe(700);
|
|
452
|
+
|
|
453
|
+
const submitCall = calls.find((args) =>
|
|
454
|
+
args.some((arg) => arg.includes("submitPullRequestReview")),
|
|
455
|
+
);
|
|
456
|
+
expect(submitCall).toBeUndefined();
|
|
457
|
+
const threadCall = calls.find((args) =>
|
|
458
|
+
args.some((arg) => arg.includes("addPullRequestReviewThread")),
|
|
459
|
+
);
|
|
460
|
+
expect(threadCall).toContain("review=REV_OLD");
|
|
250
461
|
});
|
|
251
462
|
|
|
252
463
|
test("linha fora do diff cai no comentário de arquivo", () => {
|
|
253
464
|
const state = stateWith([finding({ id: "a" })]);
|
|
254
|
-
const { runner } =
|
|
255
|
-
args.includes("subject_type=file")
|
|
256
|
-
? okComment(150)
|
|
257
|
-
: {
|
|
258
|
-
ok: false,
|
|
259
|
-
code: 422,
|
|
260
|
-
stderr: "line must be part of the diff",
|
|
261
|
-
stdout: "",
|
|
262
|
-
},
|
|
263
|
-
);
|
|
465
|
+
const { runner } = reviewRunner({ threadError: "line must be part of the diff" });
|
|
264
466
|
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
265
467
|
|
|
266
468
|
expect(outcome.failed).toEqual([]);
|
|
267
469
|
expect(outcome.general).toBeUndefined();
|
|
268
|
-
expect(
|
|
269
|
-
|
|
270
|
-
commentId: 150,
|
|
271
|
-
});
|
|
470
|
+
expect(outcome.posted[0]).toMatchObject({ kind: "file", commentId: 800 });
|
|
471
|
+
expect(state.findings.a.github).toMatchObject({ kind: "file", commentId: 800 });
|
|
272
472
|
});
|
|
273
473
|
|
|
274
474
|
test("o que não cola em linha nem arquivo vai para o comentário geral", () => {
|
|
275
475
|
const state = stateWith([finding({ id: "a" })]);
|
|
276
|
-
const { runner } =
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
);
|
|
476
|
+
const { runner } = reviewRunner({
|
|
477
|
+
threadError: "fora do diff",
|
|
478
|
+
fileError: true,
|
|
479
|
+
});
|
|
281
480
|
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
282
481
|
|
|
283
482
|
expect(outcome.failed).toHaveLength(1);
|
|
284
483
|
expect(outcome.general?.ids).toEqual(["a"]);
|
|
285
484
|
expect(state.findings.a.github?.kind).toBe("comment");
|
|
286
|
-
expect(state.findings.a.github?.commentId).toBe(
|
|
485
|
+
expect(state.findings.a.github?.commentId).toBe(900);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("finding sem linha continua comentário de arquivo, sem revisão", () => {
|
|
489
|
+
const state = stateWith([finding({ id: "a", line: null })]);
|
|
490
|
+
const { runner, calls } = reviewRunner();
|
|
491
|
+
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
492
|
+
|
|
493
|
+
expect(outcome.review).toBeUndefined();
|
|
494
|
+
expect(outcome.posted[0]).toMatchObject({ kind: "file", commentId: 800 });
|
|
495
|
+
expect(state.findings.a.github?.kind).toBe("file");
|
|
496
|
+
expect(calls.some((args) => args.includes("subject_type=file"))).toBe(true);
|
|
497
|
+
expect(calls.some((args) => args[1] === "graphql")).toBe(false);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("mistura linha na revisão e finding sem linha no arquivo", () => {
|
|
501
|
+
const state = stateWith([finding({ id: "a" }), finding({ id: "c", line: null })]);
|
|
502
|
+
const { runner } = reviewRunner();
|
|
503
|
+
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
504
|
+
|
|
505
|
+
expect(outcome.review?.created).toBe(true);
|
|
506
|
+
expect(outcome.posted.map((entry) => entry.kind).sort()).toEqual([
|
|
507
|
+
"file",
|
|
508
|
+
"line",
|
|
509
|
+
]);
|
|
510
|
+
expect(state.findings.c.github?.kind).toBe("file");
|
|
287
511
|
});
|
|
288
512
|
|
|
289
513
|
test("pula já enviados (sem chamar o gh)", () => {
|
|
290
514
|
const state = stateWith([finding({ id: "a", commentId: 55 })]);
|
|
291
|
-
const { runner, calls } =
|
|
515
|
+
const { runner, calls } = reviewRunner();
|
|
292
516
|
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
293
517
|
|
|
294
518
|
expect(calls).toHaveLength(0);
|
|
@@ -296,25 +520,150 @@ describe("submitFindings", () => {
|
|
|
296
520
|
expect(state.findings.a.github?.commentId).toBe(55);
|
|
297
521
|
});
|
|
298
522
|
|
|
299
|
-
test("manda
|
|
523
|
+
test("manda path, line e body na thread da revisão", () => {
|
|
300
524
|
const state = stateWith([finding({ id: "a", line: 9 })]);
|
|
301
|
-
const { runner, calls } =
|
|
525
|
+
const { runner, calls } = reviewRunner({
|
|
526
|
+
pending: { id: "REV_OLD", databaseId: 42 },
|
|
527
|
+
});
|
|
302
528
|
submitFindings({ state, target: TARGET, runner });
|
|
303
529
|
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
expect(
|
|
308
|
-
expect(
|
|
530
|
+
const thread = calls.find((args) =>
|
|
531
|
+
args.some((arg) => arg.includes("addPullRequestReviewThread")),
|
|
532
|
+
);
|
|
533
|
+
expect(thread).toContain("path=src/a.ts");
|
|
534
|
+
expect(thread).toContain("line=9");
|
|
535
|
+
expect(thread).toContain("review=REV_OLD");
|
|
536
|
+
expect(thread?.some((arg) => arg.startsWith("body="))).toBe(true);
|
|
309
537
|
});
|
|
310
538
|
|
|
311
|
-
test("
|
|
312
|
-
const state = stateWith([finding({ id: "a"
|
|
313
|
-
const { runner
|
|
314
|
-
|
|
539
|
+
test("falha ao finalizar reporta o erro e mantém o vínculo", () => {
|
|
540
|
+
const state = stateWith([finding({ id: "a" })]);
|
|
541
|
+
const { runner } = reviewRunner({
|
|
542
|
+
submitError: "review não pode ser submetida",
|
|
543
|
+
});
|
|
544
|
+
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
315
545
|
|
|
316
|
-
expect(
|
|
317
|
-
|
|
318
|
-
|
|
546
|
+
expect(outcome.review).toMatchObject({
|
|
547
|
+
created: true,
|
|
548
|
+
finalized: false,
|
|
549
|
+
error: "review não pode ser submetida",
|
|
550
|
+
});
|
|
551
|
+
expect(state.findings.a.github?.commentId).toBe(700);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("falha no lookup da revisão não volta a comentários soltos", () => {
|
|
555
|
+
const state = stateWith([finding({ id: "a" })]);
|
|
556
|
+
const { runner, calls } = reviewRunner({ lookupError: "sem permissão" });
|
|
557
|
+
const outcome = submitFindings({ state, target: TARGET, runner });
|
|
558
|
+
|
|
559
|
+
expect(outcome.posted).toEqual([]);
|
|
560
|
+
expect(outcome.general).toBeUndefined();
|
|
561
|
+
expect(outcome.failed).toEqual([{ id: "a", reason: "sem permissão" }]);
|
|
562
|
+
expect(outcome.review).toMatchObject({
|
|
563
|
+
created: false,
|
|
564
|
+
finalized: false,
|
|
565
|
+
error: "sem permissão",
|
|
566
|
+
});
|
|
567
|
+
expect(calls.some((args) => args.includes("subject_type=file"))).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe("resolveFindingThreads", () => {
|
|
572
|
+
const NOW = "2026-02-02T00:00:00.000Z";
|
|
573
|
+
|
|
574
|
+
test("resolve a thread aberta e grava threadId/resolvedAt", () => {
|
|
575
|
+
const state = stateWith([finding({ id: "a", commentId: 700 })]);
|
|
576
|
+
const { runner, calls } = reviewRunner({
|
|
577
|
+
threads: [{ id: "THREAD_700", commentId: 700 }],
|
|
578
|
+
});
|
|
579
|
+
const outcome = resolveFindingThreads({
|
|
580
|
+
target: TARGET,
|
|
581
|
+
findings: Object.values(state.findings),
|
|
582
|
+
runner,
|
|
583
|
+
now: NOW,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(outcome.checked).toBe(1);
|
|
587
|
+
expect(outcome.resolved).toEqual([{ id: "a", threadId: "THREAD_700" }]);
|
|
588
|
+
expect(state.findings.a.github).toMatchObject({
|
|
589
|
+
threadId: "THREAD_700",
|
|
590
|
+
resolvedAt: NOW,
|
|
591
|
+
});
|
|
592
|
+
expect(outcome.updated).toEqual(["a"]);
|
|
593
|
+
expect(
|
|
594
|
+
calls.some((args) => args.some((arg) => arg.includes("resolveReviewThread"))),
|
|
595
|
+
).toBe(true);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("pula thread já resolvida (sem mutation)", () => {
|
|
599
|
+
const state = stateWith([finding({ id: "a", commentId: 700 })]);
|
|
600
|
+
const { runner, calls } = reviewRunner({
|
|
601
|
+
threads: [{ id: "THREAD_700", commentId: 700, isResolved: true }],
|
|
602
|
+
});
|
|
603
|
+
const outcome = resolveFindingThreads({
|
|
604
|
+
target: TARGET,
|
|
605
|
+
findings: Object.values(state.findings),
|
|
606
|
+
runner,
|
|
607
|
+
now: NOW,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
expect(outcome.resolved).toEqual([]);
|
|
611
|
+
expect(outcome.skipped).toEqual([{ id: "a", reason: "já resolvida no GitHub." }]);
|
|
612
|
+
expect(state.findings.a.github?.resolvedAt).toBe(NOW);
|
|
613
|
+
expect(
|
|
614
|
+
calls.some((args) => args.some((arg) => arg.includes("resolveReviewThread"))),
|
|
615
|
+
).toBe(false);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("comentário geral não é thread (não consulta o gh)", () => {
|
|
619
|
+
const state = stateWith([finding({ id: "a", commentId: 900 })]);
|
|
620
|
+
const link = state.findings.a.github;
|
|
621
|
+
if (link) link.kind = "comment";
|
|
622
|
+
const { runner, calls } = reviewRunner();
|
|
623
|
+
|
|
624
|
+
const outcome = resolveFindingThreads({
|
|
625
|
+
target: TARGET,
|
|
626
|
+
findings: Object.values(state.findings),
|
|
627
|
+
runner,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
expect(outcome.checked).toBe(0);
|
|
631
|
+
expect(calls).toHaveLength(0);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("falha do gh vira failed (fail-open)", () => {
|
|
635
|
+
const state = stateWith([finding({ id: "a", commentId: 700 })]);
|
|
636
|
+
const { runner } = reviewRunner({ threadsError: "sem permissão" });
|
|
637
|
+
const outcome = resolveFindingThreads({
|
|
638
|
+
target: TARGET,
|
|
639
|
+
findings: Object.values(state.findings),
|
|
640
|
+
runner,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
expect(outcome.failed).toEqual([{ id: "a", reason: "sem permissão" }]);
|
|
644
|
+
expect(state.findings.a.github?.resolvedAt).toBeUndefined();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("syncResolvedThreads resolve o alvo via gh pr view", () => {
|
|
648
|
+
const state = stateWith([finding({ id: "a", commentId: 700 })]);
|
|
649
|
+
const { runner, calls } = reviewRunner({
|
|
650
|
+
threads: [{ id: "THREAD_700", commentId: 700 }],
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const outcome = syncResolvedThreads({ state, ids: ["a"], runner, now: NOW });
|
|
654
|
+
|
|
655
|
+
expect(outcome.available).toBe(true);
|
|
656
|
+
expect(outcome.resolved).toHaveLength(1);
|
|
657
|
+
expect(calls.some((args) => args[0] === "pr" && args[1] === "view")).toBe(true);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("syncResolvedThreads sem vínculo remoto não chama o gh", () => {
|
|
661
|
+
const state = stateWith([finding({ id: "a" })]);
|
|
662
|
+
const { runner, calls } = reviewRunner();
|
|
663
|
+
|
|
664
|
+
const outcome = syncResolvedThreads({ state, ids: ["a"], runner });
|
|
665
|
+
|
|
666
|
+
expect(outcome.checked).toBe(0);
|
|
667
|
+
expect(calls).toHaveLength(0);
|
|
319
668
|
});
|
|
320
669
|
});
|
|
@@ -9,10 +9,11 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import type { GitResult, GitRunner } from "../core/git-runner";
|
|
12
|
-
import { RunLayout } from "../core/run-layout";
|
|
13
12
|
import { GhClient, type GhRunner } from "../core/services/gh-client";
|
|
14
13
|
import { GitRepository } from "../core/services/git-repository";
|
|
15
14
|
import { ProjectFiles } from "../core/services/project-files";
|
|
15
|
+
import type { ReviewRecord } from "../core/storage/types";
|
|
16
|
+
import { ReviewWorkArea } from "../core/storage/work-area";
|
|
16
17
|
|
|
17
18
|
/** Runner roteado por `args.join(" ")`, registrando as chamadas. */
|
|
18
19
|
function fakeGit(routes: Record<string, GitResult>): {
|
|
@@ -163,48 +164,42 @@ describe("ProjectFiles", () => {
|
|
|
163
164
|
});
|
|
164
165
|
});
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
186
|
-
expect(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
expect(layout.findingFile("alpha").replace(/\\/g, "/")).toBe(
|
|
190
|
-
".houndsman/.runs/feature-x/findings/alpha.json",
|
|
167
|
+
/** Review fake para testar o layout do staging. */
|
|
168
|
+
function reviewFixture(): ReviewRecord {
|
|
169
|
+
return {
|
|
170
|
+
id: "review-uuid",
|
|
171
|
+
repoKey: "path:/tmp/repo",
|
|
172
|
+
repoRoot: "/tmp/repo",
|
|
173
|
+
branch: "feature/x",
|
|
174
|
+
slug: "feature-x",
|
|
175
|
+
status: null,
|
|
176
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
177
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe("ReviewWorkArea", () => {
|
|
182
|
+
test("centraliza os caminhos do staging (slug normalizado)", () => {
|
|
183
|
+
const work = new ReviewWorkArea(reviewFixture(), "./work");
|
|
184
|
+
const root = work.root.replace(/\\/g, "/");
|
|
185
|
+
expect(root).toBe("work/review-uuid");
|
|
186
|
+
expect(work.briefsDir.replace(/\\/g, "/")).toBe("work/review-uuid/briefs");
|
|
187
|
+
expect(work.findingsDir.replace(/\\/g, "/")).toBe("work/review-uuid/findings");
|
|
188
|
+
expect(work.findingFile("alpha").replace(/\\/g, "/")).toBe(
|
|
189
|
+
"work/review-uuid/findings/alpha.json",
|
|
191
190
|
);
|
|
192
|
-
expect(
|
|
193
|
-
"
|
|
191
|
+
expect(work.briefFile("alpha").replace(/\\/g, "/")).toBe(
|
|
192
|
+
"work/review-uuid/briefs/alpha.md",
|
|
194
193
|
);
|
|
195
194
|
});
|
|
196
195
|
|
|
197
|
-
test("contextPaths compartilha a pasta da
|
|
198
|
-
const paths = new
|
|
199
|
-
expect(paths.dir.replace(/\\/g, "/")).toBe("
|
|
200
|
-
expect(paths.context.replace(/\\/g, "/")).toBe(
|
|
201
|
-
".houndsman/.runs/main/context.json",
|
|
202
|
-
);
|
|
196
|
+
test("contextPaths compartilha a pasta da review", () => {
|
|
197
|
+
const paths = new ReviewWorkArea(reviewFixture(), "./work").contextPaths;
|
|
198
|
+
expect(paths.dir.replace(/\\/g, "/")).toBe("work/review-uuid");
|
|
199
|
+
expect(paths.context.replace(/\\/g, "/")).toBe("work/review-uuid/context.json");
|
|
203
200
|
expect(paths.summary.replace(/\\/g, "/")).toBe(
|
|
204
|
-
"
|
|
205
|
-
);
|
|
206
|
-
expect(paths.brief.replace(/\\/g, "/")).toBe(
|
|
207
|
-
".houndsman/.runs/main/context-brief.md",
|
|
201
|
+
"work/review-uuid/context-summary.json",
|
|
208
202
|
);
|
|
203
|
+
expect(paths.brief.replace(/\\/g, "/")).toBe("work/review-uuid/context-brief.md");
|
|
209
204
|
});
|
|
210
205
|
});
|