@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.
Files changed (91) hide show
  1. package/README.md +25 -39
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.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
- * Um comentário por finding via `POST repos/{repo}/pulls/{n}/comments`:
6
- * **na linha** do diff quando o finding tem linha; **no arquivo**
7
- * (`subject_type: file`) quando não tem. Se nenhum dos dois colar (ex.: arquivo
8
- * fora do diff), cai em **um** comentário geral da PR. O `id`/URL de cada
9
- * comentário é gravado no finding (`FindingState.github`): evita reenvio e
10
- * mantém o vínculo local↔remoto.
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
- function commentArgs(
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
- mode: "line" | "file",
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: um comentário por finding — na linha do diff
333
- * ou no arquivo (`subject_type=file`). O que não colar em nenhum dos dois vai
334
- * para um único comentário geral. Muta o `state` com o vínculo
335
- * (`finding.github`); o caller persiste.
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 = { commentId, url, kind, pr: target.number, at: now };
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
- // Tenta a linha (quando há) e depois o arquivo — o primeiro que colar vence.
371
- const modes: ("line" | "file")[] =
372
- candidate.kind === "line" ? ["line", "file"] : ["file"];
373
- let posted: PostResult | null = null;
374
- let reason = "";
375
- for (const mode of modes) {
376
- const attempt = postComment(runner, target, finding, mode);
377
- if (attempt.ok) {
378
- posted = attempt;
379
- link(finding, attempt.commentId, attempt.url, mode);
380
- outcome.posted.push({
381
- id: candidate.id,
382
- kind: mode,
383
- commentId: attempt.commentId,
384
- url: attempt.url,
385
- });
386
- break;
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
- if (!posted) {
391
- outcome.failed.push({ id: candidate.id, reason });
392
- deferred.push(finding);
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
+ }