@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
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* Envio dos findings como comentários na Pull Request (biblioteca), via GitHub
|
|
3
3
|
* CLI (`gh`) — a capability de **"enviar revisão"**.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* O envio é **agrupado numa revisão**: se já existe uma revisão PENDING do
|
|
6
|
+
* usuário logado, as threads são adicionadas a ela (sem finalizar); caso
|
|
7
|
+
* contrário o houndsman cria a revisão pendente, adiciona as threads e a
|
|
8
|
+
* finaliza (`REQUEST_CHANGES` quando há blocking; senão `COMMENT`).
|
|
9
|
+
*
|
|
10
|
+
* Findings **sem linha** (ou cuja linha não cola no diff) continuam como
|
|
11
|
+
* comentário de arquivo (`subject_type: file`) e, no limite, num único issue
|
|
12
|
+
* comment. O `id`/URL de cada comentário é gravado no finding
|
|
13
|
+
* (`FindingState.github`): evita reenvio e mantém o vínculo local↔remoto.
|
|
11
14
|
*
|
|
12
15
|
* O `gh` é OPCIONAL e falha-aberto: sem binário/login/PR a prévia volta
|
|
13
16
|
* `available:false` e o envio é no-op (como o resto do houndsman).
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
} from "./findings-state";
|
|
23
26
|
import { systemClock } from "./services/clock";
|
|
24
27
|
import { defaultGhRunner, type GhResult, type GhRunner } from "./services/gh-client";
|
|
25
|
-
import { SEVERITY_EMOJI, type Severity } from "./types";
|
|
28
|
+
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "./types";
|
|
26
29
|
|
|
27
30
|
export { type GhResult, type GhRunner, defaultGhRunner };
|
|
28
31
|
|
|
@@ -256,18 +259,283 @@ export type PostedComment = {
|
|
|
256
259
|
url?: string;
|
|
257
260
|
};
|
|
258
261
|
|
|
262
|
+
/** Evento de finalização da revisão criada pelo houndsman. */
|
|
263
|
+
export type ReviewEvent = "COMMENT" | "REQUEST_CHANGES";
|
|
264
|
+
|
|
265
|
+
/** Revisão remota (criada ou reaproveitada) do envio. */
|
|
266
|
+
export type ReviewOutcome = {
|
|
267
|
+
/** Id numérico (databaseId) da revisão, quando conhecido. */
|
|
268
|
+
id?: number;
|
|
269
|
+
url?: string;
|
|
270
|
+
/** O houndsman criou a revisão nesta execução. */
|
|
271
|
+
created: boolean;
|
|
272
|
+
/** A revisão criada foi submetida ao fim do envio. */
|
|
273
|
+
finalized: boolean;
|
|
274
|
+
event?: ReviewEvent;
|
|
275
|
+
/** Falha ao localizar/criar/finalizar a revisão. */
|
|
276
|
+
error?: string;
|
|
277
|
+
};
|
|
278
|
+
|
|
259
279
|
export type SubmitOutcome = {
|
|
260
280
|
posted: PostedComment[];
|
|
261
281
|
failed: { id: string; reason: string }[];
|
|
262
282
|
skipped: { id: string; reason: string }[];
|
|
263
283
|
/** Comentário geral (quando houve findings sem posição/falha de inline). */
|
|
264
284
|
general?: { commentId: number; url?: string; ids: string[] };
|
|
285
|
+
/** Revisão remota em que as threads foram postadas. */
|
|
286
|
+
review?: ReviewOutcome;
|
|
265
287
|
};
|
|
266
288
|
|
|
267
289
|
type PostResult =
|
|
268
|
-
| { ok: true; commentId: number; url?: string }
|
|
290
|
+
| { ok: true; commentId: number; url?: string; threadId?: string }
|
|
291
|
+
| { ok: false; reason: string };
|
|
292
|
+
|
|
293
|
+
type ReviewHandle = {
|
|
294
|
+
/** Node ID (GraphQL) da revisão. */
|
|
295
|
+
nodeId: string;
|
|
296
|
+
/** Id numérico (databaseId) da revisão. */
|
|
297
|
+
databaseId: number;
|
|
298
|
+
url?: string;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
type ReviewLookup =
|
|
302
|
+
| { ok: true; prNodeId: string; pending?: ReviewHandle }
|
|
303
|
+
| { ok: false; reason: string };
|
|
304
|
+
|
|
305
|
+
type ReviewHandleResult =
|
|
306
|
+
| { ok: true; review: ReviewHandle }
|
|
269
307
|
| { ok: false; reason: string };
|
|
270
308
|
|
|
309
|
+
type GraphqlVar = string | number | undefined;
|
|
310
|
+
|
|
311
|
+
type GraphqlResult =
|
|
312
|
+
| { ok: true; data: Record<string, unknown> }
|
|
313
|
+
| { ok: false; reason: string };
|
|
314
|
+
|
|
315
|
+
/** Objeto JSON como record (evita `any` ao navegar nas respostas do GraphQL). */
|
|
316
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
317
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
318
|
+
return value as Record<string, unknown>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Converte `resourcePath` do GraphQL na URL web do GitHub. */
|
|
322
|
+
function githubUrl(resourcePath: unknown): string | undefined {
|
|
323
|
+
return typeof resourcePath === "string"
|
|
324
|
+
? `https://github.com${resourcePath}`
|
|
325
|
+
: undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Executa uma operação GraphQL via `gh api graphql`. Falha-aberto. */
|
|
329
|
+
function runGraphql(
|
|
330
|
+
runner: GhRunner,
|
|
331
|
+
query: string,
|
|
332
|
+
vars: Record<string, GraphqlVar> = {},
|
|
333
|
+
): GraphqlResult {
|
|
334
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
335
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
336
|
+
if (value === undefined) continue;
|
|
337
|
+
args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`);
|
|
338
|
+
}
|
|
339
|
+
const result = runner(args);
|
|
340
|
+
if (!result.ok) return { ok: false, reason: shortError(result) };
|
|
341
|
+
|
|
342
|
+
let payload: { data?: Record<string, unknown>; errors?: { message?: string }[] };
|
|
343
|
+
try {
|
|
344
|
+
payload = JSON.parse(result.stdout) as typeof payload;
|
|
345
|
+
} catch {
|
|
346
|
+
return { ok: false, reason: "resposta inválida do gh." };
|
|
347
|
+
}
|
|
348
|
+
if (payload.errors?.length) {
|
|
349
|
+
const message = payload.errors
|
|
350
|
+
.map((error) => error.message)
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.join("; ");
|
|
353
|
+
return { ok: false, reason: message || "erro do GraphQL." };
|
|
354
|
+
}
|
|
355
|
+
if (!payload.data) return { ok: false, reason: "resposta sem dados do GraphQL." };
|
|
356
|
+
return { ok: true, data: payload.data };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const LOOKUP_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
360
|
+
viewer { login }
|
|
361
|
+
repository(owner: $owner, name: $name) {
|
|
362
|
+
pullRequest(number: $number) {
|
|
363
|
+
id
|
|
364
|
+
reviews(last: 50, states: [PENDING]) {
|
|
365
|
+
nodes { id databaseId resourcePath author { login } }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}`;
|
|
370
|
+
|
|
371
|
+
const CREATE_REVIEW_MUTATION = `mutation($pr: ID!, $sha: GitObjectID) {
|
|
372
|
+
addPullRequestReview(input: { pullRequestId: $pr, commitOID: $sha }) {
|
|
373
|
+
pullRequestReview { id databaseId resourcePath }
|
|
374
|
+
}
|
|
375
|
+
}`;
|
|
376
|
+
|
|
377
|
+
const ADD_THREAD_MUTATION = `mutation($review: ID!, $path: String!, $line: Int!, $body: String!) {
|
|
378
|
+
addPullRequestReviewThread(input: {
|
|
379
|
+
pullRequestReviewId: $review,
|
|
380
|
+
path: $path,
|
|
381
|
+
line: $line,
|
|
382
|
+
side: RIGHT,
|
|
383
|
+
body: $body
|
|
384
|
+
}) {
|
|
385
|
+
thread {
|
|
386
|
+
id
|
|
387
|
+
comments(first: 1) { nodes { id databaseId resourcePath } }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}`;
|
|
391
|
+
|
|
392
|
+
const SUBMIT_REVIEW_MUTATION = `mutation($review: ID!, $event: PullRequestReviewEvent!, $body: String!) {
|
|
393
|
+
submitPullRequestReview(input: {
|
|
394
|
+
pullRequestReviewId: $review,
|
|
395
|
+
event: $event,
|
|
396
|
+
body: $body
|
|
397
|
+
}) {
|
|
398
|
+
pullRequestReview { id databaseId state }
|
|
399
|
+
}
|
|
400
|
+
}`;
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Localiza a PR (node id) e a revisão PENDING do usuário logado, se houver.
|
|
404
|
+
* Revisões pendentes só são visíveis para o próprio autor.
|
|
405
|
+
*/
|
|
406
|
+
export function lookupPendingReview(runner: GhRunner, target: PrTarget): ReviewLookup {
|
|
407
|
+
const [owner, name] = target.repo.split("/");
|
|
408
|
+
if (!owner || !name) return { ok: false, reason: "repositório inválido." };
|
|
409
|
+
|
|
410
|
+
const result = runGraphql(runner, LOOKUP_QUERY, {
|
|
411
|
+
owner,
|
|
412
|
+
name,
|
|
413
|
+
number: target.number,
|
|
414
|
+
});
|
|
415
|
+
if (!result.ok) return result;
|
|
416
|
+
|
|
417
|
+
const repository = asRecord(result.data.repository);
|
|
418
|
+
const pullRequest = asRecord(repository?.pullRequest);
|
|
419
|
+
const prNodeId = typeof pullRequest?.id === "string" ? pullRequest.id : undefined;
|
|
420
|
+
if (!prNodeId) return { ok: false, reason: "PR não encontrada no GraphQL." };
|
|
421
|
+
|
|
422
|
+
const reviews = asRecord(pullRequest?.reviews);
|
|
423
|
+
const viewer = asRecord(result.data.viewer);
|
|
424
|
+
const viewerLogin =
|
|
425
|
+
typeof viewer?.login === "string" ? viewer.login.toLowerCase() : undefined;
|
|
426
|
+
const nodes = (Array.isArray(reviews?.nodes) ? reviews.nodes : [])
|
|
427
|
+
.map((value) => asRecord(value))
|
|
428
|
+
.filter((node) => {
|
|
429
|
+
if (!viewerLogin) return true;
|
|
430
|
+
const author = asRecord(node?.author);
|
|
431
|
+
return (
|
|
432
|
+
typeof author?.login === "string" &&
|
|
433
|
+
author.login.toLowerCase() === viewerLogin
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
const node = nodes.at(-1);
|
|
437
|
+
const databaseId = typeof node?.databaseId === "number" ? node.databaseId : undefined;
|
|
438
|
+
const nodeId = typeof node?.id === "string" ? node.id : undefined;
|
|
439
|
+
const pending =
|
|
440
|
+
nodeId && databaseId !== undefined
|
|
441
|
+
? { nodeId, databaseId, url: githubUrl(node?.resourcePath) }
|
|
442
|
+
: undefined;
|
|
443
|
+
return { ok: true, prNodeId, pending };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Cria uma revisão PENDING (sem `event`) apontando para o head revisado. */
|
|
447
|
+
export function createPendingReview(
|
|
448
|
+
runner: GhRunner,
|
|
449
|
+
target: PrTarget,
|
|
450
|
+
prNodeId: string,
|
|
451
|
+
): ReviewHandleResult {
|
|
452
|
+
const result = runGraphql(runner, CREATE_REVIEW_MUTATION, {
|
|
453
|
+
pr: prNodeId,
|
|
454
|
+
sha: target.headSha,
|
|
455
|
+
});
|
|
456
|
+
if (!result.ok) return result;
|
|
457
|
+
|
|
458
|
+
const mutation = asRecord(result.data.addPullRequestReview);
|
|
459
|
+
const review = asRecord(mutation?.pullRequestReview);
|
|
460
|
+
const nodeId = typeof review?.id === "string" ? review.id : undefined;
|
|
461
|
+
const databaseId =
|
|
462
|
+
typeof review?.databaseId === "number" ? review.databaseId : undefined;
|
|
463
|
+
if (!nodeId || databaseId === undefined) {
|
|
464
|
+
return { ok: false, reason: "resposta sem revisão." };
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
ok: true,
|
|
468
|
+
review: { nodeId, databaseId, url: githubUrl(review?.resourcePath) },
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Adiciona uma thread de linha à revisão pendente. */
|
|
473
|
+
function addReviewThread(
|
|
474
|
+
runner: GhRunner,
|
|
475
|
+
reviewNodeId: string,
|
|
476
|
+
finding: FindingState,
|
|
477
|
+
line: number,
|
|
478
|
+
): PostResult {
|
|
479
|
+
const result = runGraphql(runner, ADD_THREAD_MUTATION, {
|
|
480
|
+
review: reviewNodeId,
|
|
481
|
+
path: finding.file,
|
|
482
|
+
line,
|
|
483
|
+
body: buildCommentBody(finding),
|
|
484
|
+
});
|
|
485
|
+
if (!result.ok) return result;
|
|
486
|
+
|
|
487
|
+
const mutation = asRecord(result.data.addPullRequestReviewThread);
|
|
488
|
+
const thread = asRecord(mutation?.thread);
|
|
489
|
+
const comments = asRecord(thread?.comments);
|
|
490
|
+
const nodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
491
|
+
const comment = asRecord(nodes[0]);
|
|
492
|
+
const commentId =
|
|
493
|
+
typeof comment?.databaseId === "number" ? comment.databaseId : undefined;
|
|
494
|
+
if (commentId === undefined) {
|
|
495
|
+
return { ok: false, reason: "resposta sem comentário." };
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
commentId,
|
|
500
|
+
url: githubUrl(comment?.resourcePath),
|
|
501
|
+
threadId: typeof thread?.id === "string" ? thread.id : undefined,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Finaliza a revisão pendente com o evento indicado e um corpo resumo. */
|
|
506
|
+
function submitReview(
|
|
507
|
+
runner: GhRunner,
|
|
508
|
+
reviewNodeId: string,
|
|
509
|
+
event: ReviewEvent,
|
|
510
|
+
body: string,
|
|
511
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
512
|
+
const result = runGraphql(runner, SUBMIT_REVIEW_MUTATION, {
|
|
513
|
+
review: reviewNodeId,
|
|
514
|
+
event,
|
|
515
|
+
body,
|
|
516
|
+
});
|
|
517
|
+
return result.ok ? { ok: true } : result;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Corpo (markdown) da revisão criada: resumo por severidade. */
|
|
521
|
+
export function renderReviewBody(candidates: readonly SubmitCandidate[]): string {
|
|
522
|
+
const counts = SEVERITY_ORDER.map((severity) => {
|
|
523
|
+
const total = candidates.filter(
|
|
524
|
+
(candidate) => candidate.severity === severity,
|
|
525
|
+
).length;
|
|
526
|
+
return total > 0 ? `${SEVERITY_EMOJI[severity]} ${total} ${severity}` : null;
|
|
527
|
+
}).filter((part): part is string => part !== null);
|
|
528
|
+
|
|
529
|
+
const lines = [
|
|
530
|
+
"## Revisão do houndsman",
|
|
531
|
+
"",
|
|
532
|
+
`${candidates.length} achado(s) revisado(s).`,
|
|
533
|
+
];
|
|
534
|
+
if (counts.length > 0) lines.push("", counts.join(" · "));
|
|
535
|
+
lines.push("", "_— houndsman_");
|
|
536
|
+
return lines.join("\n");
|
|
537
|
+
}
|
|
538
|
+
|
|
271
539
|
function parseComment(result: GhResult): PostResult {
|
|
272
540
|
if (!result.ok) return { ok: false, reason: shortError(result) };
|
|
273
541
|
try {
|
|
@@ -279,11 +547,12 @@ function parseComment(result: GhResult): PostResult {
|
|
|
279
547
|
}
|
|
280
548
|
}
|
|
281
549
|
|
|
282
|
-
|
|
550
|
+
/** Posta o finding como comentário de arquivo (`subject_type=file`). */
|
|
551
|
+
export function postFileComment(
|
|
552
|
+
runner: GhRunner,
|
|
283
553
|
target: PrTarget,
|
|
284
554
|
finding: FindingState,
|
|
285
|
-
|
|
286
|
-
): string[] {
|
|
555
|
+
): PostResult {
|
|
287
556
|
const args = [
|
|
288
557
|
"api",
|
|
289
558
|
`repos/${target.repo}/pulls/${target.number}/comments`,
|
|
@@ -291,25 +560,11 @@ function commentArgs(
|
|
|
291
560
|
`body=${buildCommentBody(finding)}`,
|
|
292
561
|
"-f",
|
|
293
562
|
`path=${finding.file}`,
|
|
563
|
+
"-f",
|
|
564
|
+
"subject_type=file",
|
|
294
565
|
];
|
|
295
|
-
if (mode === "line") {
|
|
296
|
-
args.push("-F", `line=${finding.line}`, "-f", "side=RIGHT");
|
|
297
|
-
} else {
|
|
298
|
-
// Sem linha específica: ancora no arquivo (comentário de nível de arquivo).
|
|
299
|
-
args.push("-f", "subject_type=file");
|
|
300
|
-
}
|
|
301
566
|
if (target.headSha) args.push("-f", `commit_id=${target.headSha}`);
|
|
302
|
-
return args;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Posta o comentário de review de um finding (na linha ou no arquivo). */
|
|
306
|
-
export function postComment(
|
|
307
|
-
runner: GhRunner,
|
|
308
|
-
target: PrTarget,
|
|
309
|
-
finding: FindingState,
|
|
310
|
-
mode: "line" | "file",
|
|
311
|
-
): PostResult {
|
|
312
|
-
return parseComment(runner(commentArgs(target, finding, mode)));
|
|
567
|
+
return parseComment(runner(args));
|
|
313
568
|
}
|
|
314
569
|
|
|
315
570
|
/** Posta o comentário geral (fallback) com os findings sem posição no diff. */
|
|
@@ -329,10 +584,12 @@ export function postGeneral(
|
|
|
329
584
|
}
|
|
330
585
|
|
|
331
586
|
/**
|
|
332
|
-
* Envia os findings selecionados
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* (`
|
|
587
|
+
* Envia os findings selecionados agrupando as threads numa revisão do GitHub:
|
|
588
|
+
* adiciona à revisão PENDING existente (sem finalizar) ou cria uma nova e a
|
|
589
|
+
* finaliza ao fim do disparo. Findings sem linha e falhas de thread caem no
|
|
590
|
+
* comentário de arquivo (`subject_type=file`) e, no limite, num único
|
|
591
|
+
* comentário geral. Muta o `state` com o vínculo (`finding.github`); o caller
|
|
592
|
+
* persiste.
|
|
336
593
|
*/
|
|
337
594
|
export function submitFindings(options: {
|
|
338
595
|
state: FindingsStateFile;
|
|
@@ -353,44 +610,101 @@ export function submitFindings(options: {
|
|
|
353
610
|
commentId: number,
|
|
354
611
|
url: string | undefined,
|
|
355
612
|
kind: "line" | "file" | "comment",
|
|
613
|
+
threadId?: string,
|
|
356
614
|
): void => {
|
|
357
|
-
finding.github = {
|
|
615
|
+
finding.github = {
|
|
616
|
+
commentId,
|
|
617
|
+
url,
|
|
618
|
+
kind,
|
|
619
|
+
pr: target.number,
|
|
620
|
+
at: now,
|
|
621
|
+
threadId,
|
|
622
|
+
};
|
|
358
623
|
finding.updatedAt = now;
|
|
359
624
|
};
|
|
360
625
|
|
|
626
|
+
const toSend: SubmitCandidate[] = [];
|
|
361
627
|
for (const candidate of candidates) {
|
|
362
|
-
const finding = state.findings[candidate.id];
|
|
363
|
-
if (!finding) continue;
|
|
364
|
-
|
|
365
628
|
if (candidate.skip) {
|
|
366
629
|
outcome.skipped.push({ id: candidate.id, reason: "já enviado." });
|
|
367
630
|
continue;
|
|
368
631
|
}
|
|
632
|
+
toSend.push(candidate);
|
|
633
|
+
}
|
|
369
634
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
635
|
+
const lineCandidates = toSend.filter((candidate) => candidate.kind === "line");
|
|
636
|
+
const fileCandidates = toSend.filter((candidate) => candidate.kind === "file");
|
|
637
|
+
|
|
638
|
+
// Revisão: reaproveita a pendente ou cria uma nova (só quando há linha).
|
|
639
|
+
let review: ReviewHandle | undefined;
|
|
640
|
+
let reviewCreated = false;
|
|
641
|
+
let reviewError: string | undefined;
|
|
642
|
+
if (lineCandidates.length > 0) {
|
|
643
|
+
const lookup = lookupPendingReview(runner, target);
|
|
644
|
+
if (!lookup.ok) {
|
|
645
|
+
reviewError = lookup.reason;
|
|
646
|
+
} else if (lookup.pending) {
|
|
647
|
+
review = lookup.pending;
|
|
648
|
+
} else {
|
|
649
|
+
const created = createPendingReview(runner, target, lookup.prNodeId);
|
|
650
|
+
if (created.ok) {
|
|
651
|
+
review = created.review;
|
|
652
|
+
reviewCreated = true;
|
|
653
|
+
} else {
|
|
654
|
+
reviewError = created.reason;
|
|
387
655
|
}
|
|
388
|
-
reason = attempt.reason;
|
|
389
656
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Fallback: comentário de arquivo e depois comentário geral. */
|
|
660
|
+
const postFallback = (candidate: SubmitCandidate, finding: FindingState): void => {
|
|
661
|
+
const attempt = postFileComment(runner, target, finding);
|
|
662
|
+
if (attempt.ok) {
|
|
663
|
+
link(finding, attempt.commentId, attempt.url, "file");
|
|
664
|
+
outcome.posted.push({
|
|
665
|
+
id: candidate.id,
|
|
666
|
+
kind: "file",
|
|
667
|
+
commentId: attempt.commentId,
|
|
668
|
+
url: attempt.url,
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
outcome.failed.push({ id: candidate.id, reason: attempt.reason });
|
|
673
|
+
deferred.push(finding);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Threads na revisão; falha na linha cai para arquivo/geral.
|
|
677
|
+
for (const candidate of lineCandidates) {
|
|
678
|
+
const finding = state.findings[candidate.id];
|
|
679
|
+
if (!finding || candidate.line === null) continue;
|
|
680
|
+
|
|
681
|
+
if (!review) {
|
|
682
|
+
outcome.failed.push({
|
|
683
|
+
id: candidate.id,
|
|
684
|
+
reason: reviewError ?? "revisão indisponível.",
|
|
685
|
+
});
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const attempt = addReviewThread(runner, review.nodeId, finding, candidate.line);
|
|
690
|
+
if (attempt.ok) {
|
|
691
|
+
link(finding, attempt.commentId, attempt.url, "line", attempt.threadId);
|
|
692
|
+
outcome.posted.push({
|
|
693
|
+
id: candidate.id,
|
|
694
|
+
kind: "line",
|
|
695
|
+
commentId: attempt.commentId,
|
|
696
|
+
url: attempt.url,
|
|
697
|
+
});
|
|
698
|
+
continue;
|
|
393
699
|
}
|
|
700
|
+
postFallback(candidate, finding);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Findings sem linha: comentário de arquivo (com fallback em geral).
|
|
704
|
+
for (const candidate of fileCandidates) {
|
|
705
|
+
const finding = state.findings[candidate.id];
|
|
706
|
+
if (!finding) continue;
|
|
707
|
+
postFallback(candidate, finding);
|
|
394
708
|
}
|
|
395
709
|
|
|
396
710
|
if (deferred.length > 0) {
|
|
@@ -419,5 +733,254 @@ export function submitFindings(options: {
|
|
|
419
733
|
}
|
|
420
734
|
}
|
|
421
735
|
|
|
736
|
+
// A revisão criada é finalizada; a existente fica pendente para o usuário.
|
|
737
|
+
if (lineCandidates.length > 0) {
|
|
738
|
+
if (review) {
|
|
739
|
+
const event: ReviewEvent = toSend.some(
|
|
740
|
+
(candidate) => candidate.severity === "blocking",
|
|
741
|
+
)
|
|
742
|
+
? "REQUEST_CHANGES"
|
|
743
|
+
: "COMMENT";
|
|
744
|
+
let finalized = false;
|
|
745
|
+
let error: string | undefined;
|
|
746
|
+
if (reviewCreated) {
|
|
747
|
+
const submitted = submitReview(
|
|
748
|
+
runner,
|
|
749
|
+
review.nodeId,
|
|
750
|
+
event,
|
|
751
|
+
renderReviewBody(toSend),
|
|
752
|
+
);
|
|
753
|
+
finalized = submitted.ok;
|
|
754
|
+
if (!submitted.ok) error = submitted.reason;
|
|
755
|
+
}
|
|
756
|
+
outcome.review = {
|
|
757
|
+
id: review.databaseId,
|
|
758
|
+
url: review.url,
|
|
759
|
+
created: reviewCreated,
|
|
760
|
+
finalized,
|
|
761
|
+
event: reviewCreated ? event : undefined,
|
|
762
|
+
error,
|
|
763
|
+
};
|
|
764
|
+
} else {
|
|
765
|
+
outcome.review = {
|
|
766
|
+
created: false,
|
|
767
|
+
finalized: false,
|
|
768
|
+
error: reviewError ?? "revisão indisponível.",
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return outcome;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// Resolução das threads (findings resolvidos localmente)
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
type ThreadInfo = { id: string; isResolved: boolean };
|
|
781
|
+
|
|
782
|
+
/** Threads do PR indexadas pelo `databaseId` do comentário. */
|
|
783
|
+
type ThreadsLookup =
|
|
784
|
+
| { ok: true; threads: Map<number, ThreadInfo> }
|
|
785
|
+
| { ok: false; reason: string };
|
|
786
|
+
|
|
787
|
+
const THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
788
|
+
repository(owner: $owner, name: $name) {
|
|
789
|
+
pullRequest(number: $number) {
|
|
790
|
+
reviewThreads(last: 100) {
|
|
791
|
+
nodes {
|
|
792
|
+
id
|
|
793
|
+
isResolved
|
|
794
|
+
comments(first: 50) { nodes { databaseId } }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}`;
|
|
800
|
+
|
|
801
|
+
const RESOLVE_THREAD_MUTATION = `mutation($thread: ID!) {
|
|
802
|
+
resolveReviewThread(input: { threadId: $thread }) {
|
|
803
|
+
thread { id isResolved }
|
|
804
|
+
}
|
|
805
|
+
}`;
|
|
806
|
+
|
|
807
|
+
/** Indexa as threads do PR por comentário (`databaseId`). */
|
|
808
|
+
function lookupThreads(runner: GhRunner, target: PrTarget): ThreadsLookup {
|
|
809
|
+
const [owner, name] = target.repo.split("/");
|
|
810
|
+
if (!owner || !name) return { ok: false, reason: "repositório inválido." };
|
|
811
|
+
|
|
812
|
+
const result = runGraphql(runner, THREADS_QUERY, {
|
|
813
|
+
owner,
|
|
814
|
+
name,
|
|
815
|
+
number: target.number,
|
|
816
|
+
});
|
|
817
|
+
if (!result.ok) return result;
|
|
818
|
+
|
|
819
|
+
const repository = asRecord(result.data.repository);
|
|
820
|
+
const pullRequest = asRecord(repository?.pullRequest);
|
|
821
|
+
const reviewThreads = asRecord(pullRequest?.reviewThreads);
|
|
822
|
+
const threads = new Map<number, ThreadInfo>();
|
|
823
|
+
const nodes = Array.isArray(reviewThreads?.nodes) ? reviewThreads.nodes : [];
|
|
824
|
+
for (const value of nodes) {
|
|
825
|
+
const node = asRecord(value);
|
|
826
|
+
const id = typeof node?.id === "string" ? node.id : undefined;
|
|
827
|
+
if (!id) continue;
|
|
828
|
+
const isResolved = node?.isResolved === true;
|
|
829
|
+
const comments = asRecord(node?.comments);
|
|
830
|
+
const commentNodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
831
|
+
for (const entry of commentNodes) {
|
|
832
|
+
const comment = asRecord(entry);
|
|
833
|
+
if (typeof comment?.databaseId === "number") {
|
|
834
|
+
threads.set(comment.databaseId, { id, isResolved });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return { ok: true, threads };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export type ThreadSyncOutcome = {
|
|
842
|
+
/** Findings com comentário de revisão avaliados. */
|
|
843
|
+
checked: number;
|
|
844
|
+
/** Threads resolvidas nesta execução. */
|
|
845
|
+
resolved: { id: string; threadId: string }[];
|
|
846
|
+
/** Já resolvidas no GitHub ou sem thread (ex.: comentário geral). */
|
|
847
|
+
skipped: { id: string; reason: string }[];
|
|
848
|
+
failed: { id: string; reason: string }[];
|
|
849
|
+
/** Ids cujo vínculo local mudou (`threadId`/`resolvedAt`) — persistir o estado. */
|
|
850
|
+
updated: string[];
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
/** Resultado do sync de alto nível (inclui indisponibilidade do `gh`/PR). */
|
|
854
|
+
export type ResolvedThreadsSync = ThreadSyncOutcome & {
|
|
855
|
+
available: boolean;
|
|
856
|
+
reason?: string;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const EMPTY_THREAD_OUTCOME = (): ThreadSyncOutcome => ({
|
|
860
|
+
checked: 0,
|
|
861
|
+
resolved: [],
|
|
862
|
+
skipped: [],
|
|
863
|
+
failed: [],
|
|
864
|
+
updated: [],
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Resolve no GitHub as conversas dos findings indicados, pulando as que já
|
|
869
|
+
* estão resolvidas. Muta `finding.github` (`threadId`/`resolvedAt`) para evitar
|
|
870
|
+
* reconsultas — o caller persiste o estado. Fail-open: devolve `failed`/`skipped`
|
|
871
|
+
* por finding, nunca lança.
|
|
872
|
+
*/
|
|
873
|
+
export function resolveFindingThreads(options: {
|
|
874
|
+
runner: GhRunner;
|
|
875
|
+
target: PrTarget;
|
|
876
|
+
findings: readonly FindingState[];
|
|
877
|
+
now?: string;
|
|
878
|
+
}): ThreadSyncOutcome {
|
|
879
|
+
const { runner, target } = options;
|
|
880
|
+
const now = options.now ?? systemClock();
|
|
881
|
+
const outcome = EMPTY_THREAD_OUTCOME();
|
|
882
|
+
|
|
883
|
+
// Comentário geral (issue comment) não tem thread resolvível.
|
|
884
|
+
const linked = options.findings.filter(
|
|
885
|
+
(finding) => finding.github && finding.github.kind !== "comment",
|
|
886
|
+
);
|
|
887
|
+
outcome.checked = linked.length;
|
|
888
|
+
if (linked.length === 0) return outcome;
|
|
889
|
+
|
|
890
|
+
const lookup = lookupThreads(runner, target);
|
|
891
|
+
if (!lookup.ok) {
|
|
892
|
+
for (const finding of linked) {
|
|
893
|
+
outcome.failed.push({ id: finding.id, reason: lookup.reason });
|
|
894
|
+
}
|
|
895
|
+
return outcome;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const handled = new Set<string>();
|
|
899
|
+
const updated = new Set<string>();
|
|
900
|
+
const markUpdated = (id: string): void => {
|
|
901
|
+
if (updated.has(id)) return;
|
|
902
|
+
updated.add(id);
|
|
903
|
+
outcome.updated.push(id);
|
|
904
|
+
};
|
|
905
|
+
for (const finding of linked) {
|
|
906
|
+
const link = finding.github;
|
|
907
|
+
if (!link) continue;
|
|
908
|
+
const thread = lookup.threads.get(link.commentId);
|
|
909
|
+
if (!thread) {
|
|
910
|
+
outcome.skipped.push({
|
|
911
|
+
id: finding.id,
|
|
912
|
+
reason: "thread não encontrada no PR.",
|
|
913
|
+
});
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (handled.has(thread.id)) {
|
|
917
|
+
outcome.skipped.push({
|
|
918
|
+
id: finding.id,
|
|
919
|
+
reason: "mesma thread de outro finding.",
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
handled.add(thread.id);
|
|
924
|
+
|
|
925
|
+
if (link.threadId !== thread.id) {
|
|
926
|
+
link.threadId = thread.id;
|
|
927
|
+
markUpdated(finding.id);
|
|
928
|
+
}
|
|
929
|
+
if (thread.isResolved) {
|
|
930
|
+
if (!link.resolvedAt) {
|
|
931
|
+
link.resolvedAt = now;
|
|
932
|
+
markUpdated(finding.id);
|
|
933
|
+
}
|
|
934
|
+
outcome.skipped.push({ id: finding.id, reason: "já resolvida no GitHub." });
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const result = runGraphql(runner, RESOLVE_THREAD_MUTATION, { thread: thread.id });
|
|
939
|
+
if (result.ok) {
|
|
940
|
+
link.resolvedAt = now;
|
|
941
|
+
finding.updatedAt = now;
|
|
942
|
+
markUpdated(finding.id);
|
|
943
|
+
outcome.resolved.push({ id: finding.id, threadId: thread.id });
|
|
944
|
+
} else {
|
|
945
|
+
outcome.failed.push({ id: finding.id, reason: result.reason });
|
|
946
|
+
}
|
|
947
|
+
}
|
|
422
948
|
return outcome;
|
|
423
949
|
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Sincroniza as threads dos findings já resolvidos localmente (`done`):
|
|
953
|
+
* resolve o alvo via `gh pr view` e delega ao `resolveFindingThreads`.
|
|
954
|
+
* Fail-open — sem `gh`/login/PR devolve `available:false` sem lançar.
|
|
955
|
+
*/
|
|
956
|
+
export function syncResolvedThreads(options: {
|
|
957
|
+
state: FindingsStateFile;
|
|
958
|
+
ids: readonly string[];
|
|
959
|
+
runner?: GhRunner;
|
|
960
|
+
now?: string;
|
|
961
|
+
}): ResolvedThreadsSync {
|
|
962
|
+
const runner = options.runner ?? defaultGhRunner;
|
|
963
|
+
const findings = options.ids
|
|
964
|
+
.map((id) => options.state.findings[id])
|
|
965
|
+
.filter(
|
|
966
|
+
(finding): finding is FindingState =>
|
|
967
|
+
Boolean(finding?.github) && finding?.github?.kind !== "comment",
|
|
968
|
+
);
|
|
969
|
+
if (findings.length === 0) return { available: true, ...EMPTY_THREAD_OUTCOME() };
|
|
970
|
+
|
|
971
|
+
const target = resolvePrTarget(runner, options.state.branch);
|
|
972
|
+
if (!target.ok) {
|
|
973
|
+
const outcome = EMPTY_THREAD_OUTCOME();
|
|
974
|
+
outcome.checked = findings.length;
|
|
975
|
+
return { available: false, reason: target.reason, ...outcome };
|
|
976
|
+
}
|
|
977
|
+
return {
|
|
978
|
+
available: true,
|
|
979
|
+
...resolveFindingThreads({
|
|
980
|
+
runner,
|
|
981
|
+
target: target.target,
|
|
982
|
+
findings,
|
|
983
|
+
now: options.now,
|
|
984
|
+
}),
|
|
985
|
+
};
|
|
986
|
+
}
|