@payconductor/houndsman 0.6.0 → 0.7.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.
@@ -97,6 +97,86 @@ export type FindingsResponse = {
97
97
  summary: Summary;
98
98
  };
99
99
 
100
+ /** Editor detectado pelo servidor (base dos links de código). */
101
+ export type EditorKind = "vscode" | "jetbrains" | "none";
102
+
103
+ export type EditorContext = {
104
+ kind: EditorKind;
105
+ project?: string;
106
+ product?: string;
107
+ };
108
+
109
+ /** Estado da árvore de trabalho (bloqueia o checkout quando suja). */
110
+ export type WorktreeState = {
111
+ available: boolean;
112
+ clean: boolean;
113
+ changes: number;
114
+ sample: string[];
115
+ };
116
+
117
+ /** PR aberta exibida na sidebar. */
118
+ export type PrListItem = {
119
+ number: number;
120
+ title: string;
121
+ headRefName: string;
122
+ baseRefName: string;
123
+ author: string;
124
+ updatedAt?: string;
125
+ isDraft: boolean;
126
+ reviewDecision?: string;
127
+ url?: string;
128
+ runSlug?: string;
129
+ };
130
+
131
+ export type PrListResponse = {
132
+ available: boolean;
133
+ reason?: string;
134
+ currentBranch: string | null;
135
+ worktree: WorktreeState;
136
+ prs: PrListItem[];
137
+ };
138
+
139
+ export type CheckoutResponse =
140
+ | { ok: true; number: number; branch: string | null }
141
+ | {
142
+ ok: false;
143
+ reason: "dirty" | "git" | "gh";
144
+ detail: string;
145
+ changes?: string[];
146
+ };
147
+
148
+ /** Comentário normalizado da PR (thread ou geral). */
149
+ export type PrCommentView = {
150
+ id: number;
151
+ author: string;
152
+ authorType: string;
153
+ body: string;
154
+ createdAt?: string;
155
+ url?: string;
156
+ path?: string;
157
+ line: number | null;
158
+ absFile?: string;
159
+ replyTo?: number | null;
160
+ };
161
+
162
+ export type PrCommentThread = {
163
+ id: number;
164
+ path: string;
165
+ line: number | null;
166
+ absFile: string;
167
+ comments: PrCommentView[];
168
+ };
169
+
170
+ export type PrCommentsResponse = {
171
+ available: boolean;
172
+ reason?: string;
173
+ target?: { number: number; repo: string; url?: string };
174
+ fetchedAt: string;
175
+ threads: PrCommentThread[];
176
+ issueComments: PrCommentView[];
177
+ counts: { threads: number; inline: number; issue: number };
178
+ };
179
+
100
180
  /** Manifesto da run (subset consumido pela aba de resumo). */
101
181
  export type RunManifest = {
102
182
  branch?: string;
@@ -149,6 +229,8 @@ export type SubmitCandidate = {
149
229
  file: string;
150
230
  line: number | null;
151
231
  severity: Severity;
232
+ /** Decisão da triagem (`execute` = "a executar"). */
233
+ decision: Decision;
152
234
  execState: ExecState;
153
235
  rule: string;
154
236
  message: string;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Detecção do editor que hospeda o terminal do servidor (biblioteca do servidor).
3
+ *
4
+ * VS Code e JetBrains expõem variáveis próprias no terminal integrado. O
5
+ * contexto alimenta os links de código da UI (`vscode://` / `jetbrains://`).
6
+ * A detecção é pura e fail-open: sem pistas, o editor fica `none`.
7
+ */
8
+
9
+ import { basename, resolve } from "node:path";
10
+
11
+ /** Editores com esquema de URL próprio. */
12
+ export type EditorKind = "vscode" | "jetbrains" | "none";
13
+
14
+ /** Contexto usado pelo cliente para montar os links de código. */
15
+ export type EditorContext = {
16
+ kind: EditorKind;
17
+ /** Nome do projeto aberto no IDE (basename da raiz do repositório). */
18
+ project?: string;
19
+ /** Código do produto JetBrains (`idea`, `webstorm`, `pycharm`…). */
20
+ product?: string;
21
+ };
22
+
23
+ type Env = Record<string, string | undefined>;
24
+
25
+ /** Bundle macOS → código do produto usado no esquema `jetbrains://`. */
26
+ const BUNDLE_PRODUCTS: Record<string, string> = {
27
+ "com.jetbrains.intellij": "idea",
28
+ "com.jetbrains.intellij.ce": "idea",
29
+ "com.jetbrains.pycharm": "pycharm",
30
+ "com.jetbrains.pycharm.ce": "pycharm",
31
+ "com.jetbrains.webstorm": "webstorm",
32
+ "com.jetbrains.goland": "goland",
33
+ "com.jetbrains.clion": "clion",
34
+ "com.jetbrains.rider": "rider",
35
+ "com.jetbrains.phpstorm": "phpstorm",
36
+ "com.jetbrains.rubymine": "rubymine",
37
+ "com.jetbrains.datagrip": "datagrip",
38
+ "com.jetbrains.appcode": "appcode",
39
+ "com.jetbrains.rustrover": "rustrover",
40
+ };
41
+
42
+ /** Variáveis `*_INITIAL_DIRECTORY` → produto JetBrains. */
43
+ const DIR_PRODUCTS: [string, string][] = [
44
+ ["IDEA_INITIAL_DIRECTORY", "idea"],
45
+ ["PYCHARM_INITIAL_DIRECTORY", "pycharm"],
46
+ ["WEBSTORM_INITIAL_DIRECTORY", "webstorm"],
47
+ ["GOLAND_INITIAL_DIRECTORY", "goland"],
48
+ ["CLION_INITIAL_DIRECTORY", "clion"],
49
+ ["RIDER_INITIAL_DIRECTORY", "rider"],
50
+ ["PHPSTORM_INITIAL_DIRECTORY", "phpstorm"],
51
+ ["RUBYMINE_INITIAL_DIRECTORY", "rubymine"],
52
+ ["DATAGRIP_INITIAL_DIRECTORY", "datagrip"],
53
+ ["RUSTROVER_INITIAL_DIRECTORY", "rustrover"],
54
+ ];
55
+
56
+ function projectName(repoRoot: string): string {
57
+ return basename(resolve(repoRoot));
58
+ }
59
+
60
+ /** Produto JetBrains detectado no ambiente (undefined quando ambíguo). */
61
+ export function detectJetBrainsProduct(env: Env): string | undefined {
62
+ const bundle = env.__CFBundleIdentifier?.toLowerCase();
63
+ if (bundle) {
64
+ const exact = BUNDLE_PRODUCTS[bundle];
65
+ if (exact) return exact;
66
+ const prefix = Object.keys(BUNDLE_PRODUCTS).find((key) => bundle.startsWith(key));
67
+ if (prefix) return BUNDLE_PRODUCTS[prefix];
68
+ }
69
+ for (const [name, product] of DIR_PRODUCTS) {
70
+ if (env[name]) return product;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function isJetBrains(env: Env): boolean {
76
+ const bundle = env.__CFBundleIdentifier?.toLowerCase() ?? "";
77
+ return (
78
+ /jetbrains/i.test(env.TERMINAL_EMULATOR ?? "") ||
79
+ bundle.startsWith("com.jetbrains.") ||
80
+ DIR_PRODUCTS.some(([name]) => Boolean(env[name]))
81
+ );
82
+ }
83
+
84
+ function isVsCode(env: Env): boolean {
85
+ return (
86
+ env.TERM_PROGRAM === "vscode" ||
87
+ env.TERM_PROGRAM === "vscode-insiders" ||
88
+ Boolean(env.VSCODE_PID || env.VSCODE_IPC_HOOK_CLI || env.VSCODE_GIT_IPC_HANDLE)
89
+ );
90
+ }
91
+
92
+ /** Detecta o editor a partir do ambiente do terminal integrado. */
93
+ export function detectEditor(env: Env, repoRoot: string): EditorContext {
94
+ const project = projectName(repoRoot);
95
+ if (isJetBrains(env)) {
96
+ return {
97
+ kind: "jetbrains",
98
+ project,
99
+ product: detectJetBrainsProduct(env) ?? "idea",
100
+ };
101
+ }
102
+ if (isVsCode(env)) return { kind: "vscode", project };
103
+ return { kind: "none", project };
104
+ }
105
+
106
+ /**
107
+ * Resolve a preferência do `--editor`.
108
+ *
109
+ * `auto` (ou ausente) detecta do ambiente; `vscode`/`jetbrains`/`none` forçam
110
+ * o comportamento. Valores desconhecidos caem na detecção automática.
111
+ */
112
+ export function resolveEditor(
113
+ preference: string | undefined,
114
+ env: Env,
115
+ repoRoot: string,
116
+ ): EditorContext {
117
+ const value = preference?.trim().toLowerCase();
118
+ const project = projectName(repoRoot);
119
+ if (value === "vscode") return { kind: "vscode", project };
120
+ if (value === "jetbrains") {
121
+ return {
122
+ kind: "jetbrains",
123
+ project,
124
+ product: detectJetBrainsProduct(env) ?? "idea",
125
+ };
126
+ }
127
+ if (value === "none") return { kind: "none", project };
128
+ return detectEditor(env, repoRoot);
129
+ }
@@ -9,16 +9,20 @@
9
9
  import { mediaPath } from "../core/assets";
10
10
  import type { HoundsmanConfig } from "../core/config";
11
11
  import type { FindingUpdate } from "../core/findings-state";
12
+ import { createGitRunner, type GitRunner } from "../core/git-runner";
12
13
  import {
13
14
  defaultGhRunner,
14
15
  type GhRunner,
15
16
  type SubmitSelection,
16
17
  } from "../core/github-review";
17
18
  import { logger } from "../core/logging";
19
+ import { GitRepository } from "../core/services/git-repository";
18
20
  import { createStorage } from "../core/storage/factory";
19
21
  import { resolveRepoIdentity } from "../core/storage/repo-identity";
20
22
  import type { RepoIdentity, StorageProvider } from "../core/storage/types";
21
23
  import { buildClientBundle } from "./client-build";
24
+ import { type EditorContext, resolveEditor } from "./editor";
25
+ import { PrInbox } from "./pr-inbox";
22
26
  import { RunRepository } from "./run-repository";
23
27
 
24
28
  type BunServer = ReturnType<typeof Bun.serve>;
@@ -59,8 +63,13 @@ function notFound(detail = "não encontrado"): Response {
59
63
  /** Repositório das runs (dados) — ver `run-repository.ts`. */
60
64
  type ServerDeps = {
61
65
  ghRunner?: GhRunner;
66
+ gitRunner?: GitRunner;
62
67
  storage?: StorageProvider;
63
68
  repo?: RepoIdentity;
69
+ /** Contexto do editor injetado (testes). */
70
+ editor?: EditorContext;
71
+ /** Preferência do `--editor` (CLI). */
72
+ editorPreference?: string;
64
73
  };
65
74
 
66
75
  // ---------------------------------------------------------------------------
@@ -83,6 +92,8 @@ async function route(
83
92
  request: Request,
84
93
  runs: RunRepository,
85
94
  ghRunner: GhRunner,
95
+ prs: PrInbox,
96
+ editor: EditorContext,
86
97
  ): Promise<Response> {
87
98
  const url = new URL(request.url);
88
99
  const path = url.pathname;
@@ -95,6 +106,21 @@ async function route(
95
106
  }
96
107
  if (path === `/assets/${LOGO_FILE}`) return serveLogo();
97
108
  if (path === "/api/health") return json({ ok: true });
109
+ if (path === "/api/editor" && request.method === "GET") return json(editor);
110
+ if (path === "/api/prs" && request.method === "GET") {
111
+ return json(prs.list(runs.branchIndex()));
112
+ }
113
+ if (path === "/api/prs/checkout" && request.method === "POST") {
114
+ const body = (await request.json().catch(() => ({}))) as { number?: unknown };
115
+ const number = Number(body.number);
116
+ if (!Number.isInteger(number) || number <= 0) {
117
+ return json(
118
+ { ok: false, reason: "invalid", detail: "Número de PR inválido." },
119
+ 400,
120
+ );
121
+ }
122
+ return json(prs.checkout(number));
123
+ }
98
124
 
99
125
  if (path === "/api/runs" && request.method === "GET") {
100
126
  return json({ runs: runs.list() });
@@ -108,6 +134,11 @@ async function route(
108
134
 
109
135
  if (rest === "" || rest === "/") return json(runs.detail(slug));
110
136
 
137
+ if (rest === "/comments" && request.method === "GET") {
138
+ return json(
139
+ runs.comments(slug, { refresh: url.searchParams.get("refresh") === "1" }),
140
+ );
141
+ }
111
142
  if (rest === "/findings" && request.method === "GET") {
112
143
  return json(runs.findings(slug, url.searchParams));
113
144
  }
@@ -119,6 +150,7 @@ async function route(
119
150
  const body = (await request.json().catch(() => ({}))) as SubmitSelection;
120
151
  const selection: SubmitSelection = {
121
152
  severities: body.severities,
153
+ decisions: body.decisions,
122
154
  execStates: body.execStates,
123
155
  includeSent: body.includeSent,
124
156
  };
@@ -161,19 +193,27 @@ async function route(
161
193
  export class HoundsmanServer {
162
194
  private readonly runs: RunRepository;
163
195
  private readonly ghRunner: GhRunner;
196
+ private readonly prs: PrInbox;
197
+ private readonly editor: EditorContext;
164
198
 
165
199
  constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
200
+ const repo = deps.repo ?? resolveRepoIdentity();
201
+ this.ghRunner = deps.ghRunner ?? defaultGhRunner;
166
202
  this.runs = new RunRepository(
167
203
  deps.storage ?? createStorage(config),
168
- deps.repo ?? resolveRepoIdentity(),
204
+ repo,
205
+ this.ghRunner,
169
206
  );
170
- this.ghRunner = deps.ghRunner ?? defaultGhRunner;
207
+ this.editor =
208
+ deps.editor ?? resolveEditor(deps.editorPreference, process.env, repo.root);
209
+ const git = new GitRepository(deps.gitRunner ?? createGitRunner(repo.root));
210
+ this.prs = new PrInbox(this.ghRunner, git);
171
211
  }
172
212
 
173
213
  /** Handler de requisições (testável sem abrir porta). */
174
214
  readonly handler = async (request: Request): Promise<Response> => {
175
215
  try {
176
- return await route(request, this.runs, this.ghRunner);
216
+ return await route(request, this.runs, this.ghRunner, this.prs, this.editor);
177
217
  } catch (error) {
178
218
  return json(
179
219
  { error: error instanceof Error ? error.message : String(error) },
@@ -223,17 +263,26 @@ export type ServerOptions = {
223
263
  host?: string;
224
264
  /** Runner do `gh` (injetável nos testes). */
225
265
  ghRunner?: GhRunner;
266
+ /** Runner do git (injetável nos testes). */
267
+ gitRunner?: GitRunner;
226
268
  /** Provider/storage injetável (testes). */
227
269
  storage?: StorageProvider;
228
270
  repo?: RepoIdentity;
271
+ /** Contexto de editor injetado (testes). */
272
+ editor?: EditorContext;
273
+ /** Preferência do `--editor` (auto|vscode|jetbrains|none). */
274
+ editorPreference?: string;
229
275
  };
230
276
 
231
277
  /** Sobe o servidor e devolve a instância + URL. */
232
278
  export function startServer(options: ServerOptions): { server: BunServer; url: string } {
233
279
  return new HoundsmanServer(options.config, {
234
280
  ghRunner: options.ghRunner,
281
+ gitRunner: options.gitRunner,
235
282
  storage: options.storage,
236
283
  repo: options.repo,
284
+ editor: options.editor,
285
+ editorPreference: options.editorPreference,
237
286
  }).start({
238
287
  port: options.port,
239
288
  host: options.host,
@@ -295,6 +344,66 @@ code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
295
344
  .status-review_done { color:var(--suggestion); }
296
345
  .status-fix_done { color:var(--success); }
297
346
  .status-timeout { color:var(--blocking); }
347
+ .side-tabs { display:flex; gap:4px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
348
+ .side-tab { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:7px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted);
349
+ padding:7px 10px; background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
350
+ .side-tab:hover { color:var(--fg); }
351
+ .side-tab.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); }
352
+ .side-count { padding:1px 6px; font-family:var(--font-mono); font-size:10px; color:var(--muted); background:rgba(255,255,255,.08); border-radius:999px; }
353
+ .side-tab.active .side-count { color:#1b1305; background:rgba(0,0,0,.14); }
354
+ .icon-btn { display:inline-grid; place-items:center; width:26px; height:26px; color:var(--muted); background:transparent; border:1px solid var(--border); border-radius:8px; cursor:pointer; text-decoration:none; }
355
+ .icon-btn:hover { color:var(--fg); border-color:var(--border-strong); }
356
+ .icon-btn:disabled { opacity:.5; cursor:not-allowed; }
357
+ .pr-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
358
+ .pr-toolbar .section-label { margin:0; }
359
+ .prs { display:flex; flex-direction:column; gap:8px; }
360
+ .pr { display:flex; flex-direction:column; gap:7px; padding:11px 12px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); }
361
+ .pr.current { background:var(--accent-soft); border-color:var(--accent-line); }
362
+ .pr-head { display:flex; align-items:center; gap:7px; font-family:var(--font-mono); font-size:11px; color:var(--muted); }
363
+ .pr-icon { color:var(--muted-2); }
364
+ .pr.current .pr-icon { color:var(--accent); }
365
+ .pr-number { font-weight:600; color:var(--fg); }
366
+ .pr-current { padding:1px 6px; font-size:9px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--accent); background:rgba(240,165,44,.12); border-radius:999px; }
367
+ .pr-title { margin:0; font-size:12.5px; font-weight:600; line-height:1.4; word-break:break-word; }
368
+ .pr-meta { display:flex; flex-wrap:wrap; align-items:center; gap:1px 5px; margin:0; font-family:var(--font-mono); font-size:10.5px; color:var(--muted-2); }
369
+ .pr-branch { color:var(--suggestion); }
370
+ .pr-review { margin:0; font-size:10.5px; font-weight:600; }
371
+ .pr-review-APPROVED { color:var(--success); }
372
+ .pr-review-CHANGES_REQUESTED { color:var(--blocking); }
373
+ .pr-review-REVIEW_REQUIRED { color:var(--attention); }
374
+ .pr-actions { display:flex; flex-wrap:wrap; gap:6px; margin-top:2px; }
375
+ .pr-btn { display:inline-flex; align-items:center; gap:5px; font:inherit; font-size:10.5px; font-weight:600; cursor:pointer; padding:4px 9px; color:var(--muted);
376
+ background:transparent; border:1px solid var(--border-strong); border-radius:999px; text-decoration:none; }
377
+ .pr-btn:hover { color:var(--fg); border-color:var(--accent-line); }
378
+ .pr-btn:disabled { opacity:.45; cursor:not-allowed; }
379
+ .pr-btn.danger { color:var(--accent); background:var(--accent-soft); border-color:var(--accent-line); }
380
+ .pr-warning { margin:0; padding:8px 10px; font-size:11px; line-height:1.5; color:var(--attention);
381
+ background:rgba(237,161,58,.08); border:1px solid rgba(237,161,58,.24); border-radius:10px; }
382
+ .pr-notice { margin:0; padding:8px 10px; font-size:11px; line-height:1.5; color:var(--success);
383
+ background:rgba(69,192,122,.08); border:1px solid rgba(69,192,122,.24); border-radius:10px; }
384
+ .pr-current-branch { margin:0; font-family:var(--font-mono); font-size:10.5px; color:var(--muted-2); }
385
+ .pr-current-branch code { color:var(--fg); }
386
+ .comments { display:flex; flex-direction:column; gap:18px; }
387
+ .comments-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; }
388
+ .comments-counts { display:flex; flex-wrap:wrap; align-items:center; gap:12px; font-size:11.5px; color:var(--muted); }
389
+ .comments-counts b { font-family:var(--font-mono); font-weight:600; color:var(--fg); }
390
+ .comment-group { display:flex; flex-direction:column; gap:10px; }
391
+ .comment-group-title { margin:0; font-size:11px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
392
+ .comment-file-group { display:flex; flex-direction:column; gap:8px; }
393
+ .comment-group-label { display:flex; align-items:center; gap:8px; margin:0; font-family:var(--font-mono); font-size:11.5px; color:var(--suggestion); }
394
+ .comment-thread { display:flex; flex-direction:column; overflow:hidden; background:var(--card); border:1px solid var(--border); border-radius:var(--radius-lg); }
395
+ .comment-thread-head { display:flex; align-items:center; gap:8px; padding:9px 13px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
396
+ background:var(--surface-2); border-bottom:1px solid var(--border); }
397
+ .comment-file { overflow:hidden; color:var(--fg); text-overflow:ellipsis; white-space:nowrap; }
398
+ .pr-comment { display:flex; flex-direction:column; gap:7px; padding:12px 14px; border-top:1px solid var(--border); }
399
+ .comment-thread .pr-comment:first-of-type { border-top:0; }
400
+ .pr-comment.reply { margin-left:26px; background:rgba(255,255,255,.012); border-left:2px solid var(--accent-line); }
401
+ .pr-comment-head { display:flex; align-items:center; gap:9px; font-size:11px; color:var(--muted-2); }
402
+ .pr-comment-author { font-size:12px; font-weight:600; color:var(--fg); }
403
+ .pr-comment-link { display:inline-flex; align-items:center; gap:5px; font-size:11px; color:var(--muted); text-decoration:none; }
404
+ .pr-comment-link:hover { color:var(--accent); }
405
+ .pr-comment-body { font-size:12.5px; line-height:1.6; color:#f0ece6; }
406
+ .pr-comment-body .md-p, .pr-comment-body .md-list { font-size:12.5px; }
298
407
  .main { flex:1; min-width:0; display:flex; flex-direction:column; overflow-y:auto; }
299
408
  .topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px;
300
409
  padding:13px 24px; border-bottom:1px solid var(--border); background:rgba(16,16,20,.74);
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Comentários de uma Pull Request (servidor) — threads de revisão + comentários
3
+ * gerais, lidos via `gh api`.
4
+ *
5
+ * A aba é lazy: só chama o GitHub quando o cliente pede. O resultado fica em
6
+ * cache por branch (TTL de 60s); `refresh` ignora o cache. Tudo fail-open: sem
7
+ * PR/gh/login, devolve `available:false` e a UI mostra o motivo.
8
+ */
9
+
10
+ import { resolve } from "node:path";
11
+ import { resolvePrTarget } from "../core/github-review";
12
+ import { systemClock } from "../core/services/clock";
13
+ import { describeGhError, type GhRunner } from "../core/services/gh-client";
14
+
15
+ /** Cache do payload já processado (por raiz do repositório + branch). */
16
+ const CACHE_TTL_MS = 60_000;
17
+ const cache = new Map<string, { expiresAt: number; value: PrCommentsResponse }>();
18
+
19
+ /** Limpa o cache (testes e shutdown). */
20
+ export function clearPrCommentsCache(): void {
21
+ cache.clear();
22
+ }
23
+
24
+ /** Comentário já normalizado para a UI. */
25
+ export type PrCommentView = {
26
+ id: number;
27
+ author: string;
28
+ authorType: string;
29
+ body: string;
30
+ createdAt?: string;
31
+ url?: string;
32
+ path?: string;
33
+ line: number | null;
34
+ absFile?: string;
35
+ replyTo?: number | null;
36
+ };
37
+
38
+ /** Thread de revisão (comentário raiz + respostas). */
39
+ export type PrCommentThread = {
40
+ id: number;
41
+ path: string;
42
+ line: number | null;
43
+ absFile: string;
44
+ comments: PrCommentView[];
45
+ };
46
+
47
+ /** Resposta de `GET /api/runs/:slug/comments`. */
48
+ export type PrCommentsResponse = {
49
+ available: boolean;
50
+ reason?: string;
51
+ target?: { number: number; repo: string; url?: string };
52
+ fetchedAt: string;
53
+ threads: PrCommentThread[];
54
+ issueComments: PrCommentView[];
55
+ counts: { threads: number; inline: number; issue: number };
56
+ };
57
+
58
+ type GhInlineComment = {
59
+ id?: number;
60
+ body?: string;
61
+ path?: string;
62
+ line?: number | null;
63
+ original_line?: number | null;
64
+ created_at?: string;
65
+ html_url?: string;
66
+ in_reply_to_id?: number | null;
67
+ user?: { login?: string; type?: string };
68
+ };
69
+
70
+ type GhIssueComment = {
71
+ id?: number;
72
+ body?: string;
73
+ created_at?: string;
74
+ html_url?: string;
75
+ user?: { login?: string; type?: string };
76
+ };
77
+
78
+ type ApiList = { ok: true; items: unknown[] } | { ok: false; reason: string };
79
+
80
+ function unavailable(
81
+ reason: string,
82
+ target?: PrCommentsResponse["target"],
83
+ ): PrCommentsResponse {
84
+ return {
85
+ available: false,
86
+ reason,
87
+ target,
88
+ fetchedAt: systemClock(),
89
+ threads: [],
90
+ issueComments: [],
91
+ counts: { threads: 0, inline: 0, issue: 0 },
92
+ };
93
+ }
94
+
95
+ /** Lista paginada via `gh api`; `--slurp` junta as páginas num único array. */
96
+ function ghApiList(runner: GhRunner, path: string): ApiList {
97
+ let result = runner(["api", "--paginate", "--slurp", path]);
98
+ if (!result.ok && /unknown flag|unknown shorthand|slurp/i.test(result.stderr)) {
99
+ result = runner(["api", path]);
100
+ }
101
+ if (!result.ok) return { ok: false, reason: describeGhError(result) };
102
+ try {
103
+ const parsed = JSON.parse(result.stdout);
104
+ if (Array.isArray(parsed)) {
105
+ // Dependendo da versão do gh, `--slurp` pode devolver `[[...], [...]]`
106
+ // (um array por página) ou um array já achatado.
107
+ const items: unknown[] = [];
108
+ for (const item of parsed) {
109
+ if (Array.isArray(item)) items.push(...item);
110
+ else items.push(item);
111
+ }
112
+ return { ok: true, items };
113
+ }
114
+ return { ok: false, reason: "Resposta inesperada do gh (esperava uma lista)." };
115
+ } catch {
116
+ return { ok: false, reason: "Resposta inválida do gh (JSON malformado)." };
117
+ }
118
+ }
119
+
120
+ function toView(
121
+ raw: GhInlineComment | GhIssueComment,
122
+ repoRoot: string,
123
+ ): PrCommentView | null {
124
+ const id = Number(raw.id);
125
+ const body = raw.body?.trim() ?? "";
126
+ if (!Number.isFinite(id) || body.length === 0) return null;
127
+ const path = "path" in raw ? raw.path : undefined;
128
+ const line = "line" in raw ? (raw.line ?? raw.original_line ?? null) : null;
129
+ return {
130
+ id,
131
+ author: raw.user?.login ?? "desconhecido",
132
+ authorType: raw.user?.type ?? "User",
133
+ body,
134
+ createdAt: raw.created_at,
135
+ url: raw.html_url,
136
+ path,
137
+ line,
138
+ absFile: path ? resolve(repoRoot, path) : undefined,
139
+ replyTo: "in_reply_to_id" in raw ? (raw.in_reply_to_id ?? null) : null,
140
+ };
141
+ }
142
+
143
+ /** Agrupa comentários inline em threads (raiz + respostas). */
144
+ function buildThreads(
145
+ items: unknown[],
146
+ repoRoot: string,
147
+ ): {
148
+ threads: PrCommentThread[];
149
+ inline: number;
150
+ } {
151
+ const raws = (items as GhInlineComment[])
152
+ .filter((raw) => raw && typeof raw === "object")
153
+ .sort(
154
+ (a, b) =>
155
+ (a.created_at ?? "").localeCompare(b.created_at ?? "") ||
156
+ Number(a.id ?? 0) - Number(b.id ?? 0),
157
+ );
158
+
159
+ const views: PrCommentView[] = [];
160
+ const parent = new Map<number, number | null>();
161
+ for (const raw of raws) {
162
+ const view = toView(raw, repoRoot);
163
+ if (!view) continue;
164
+ views.push(view);
165
+ parent.set(view.id, raw.in_reply_to_id ?? null);
166
+ }
167
+
168
+ const rootOf = (id: number): number => {
169
+ let current = id;
170
+ const seen = new Set<number>();
171
+ for (;;) {
172
+ const next = parent.get(current);
173
+ if (!next || seen.has(current)) return current;
174
+ seen.add(current);
175
+ current = next;
176
+ }
177
+ };
178
+
179
+ const byRoot = new Map<number, PrCommentThread>();
180
+ for (const view of views) {
181
+ const rootId = rootOf(view.id);
182
+ const existing = byRoot.get(rootId);
183
+ if (existing) {
184
+ existing.comments.push(view);
185
+ continue;
186
+ }
187
+ const path = view.path ?? "";
188
+ byRoot.set(rootId, {
189
+ id: rootId,
190
+ path,
191
+ line: view.line,
192
+ absFile: view.absFile ?? (path ? resolve(repoRoot, path) : ""),
193
+ comments: [view],
194
+ });
195
+ }
196
+
197
+ const threads = [...byRoot.values()].sort(
198
+ (a, b) =>
199
+ a.path.localeCompare(b.path) ||
200
+ (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER) ||
201
+ a.id - b.id,
202
+ );
203
+ return { threads, inline: views.length };
204
+ }
205
+
206
+ /** Lê e agrupa os comentários da PR da branch (com cache de 60s). */
207
+ export function fetchPrComments(options: {
208
+ runner: GhRunner;
209
+ branch: string;
210
+ repoRoot: string;
211
+ refresh?: boolean;
212
+ now?: number;
213
+ }): PrCommentsResponse {
214
+ const { runner, branch, repoRoot, refresh = false } = options;
215
+ const now = options.now ?? Date.now();
216
+ const key = `${resolve(repoRoot)}\0${branch}`;
217
+ const cached = cache.get(key);
218
+ if (!refresh && cached && cached.expiresAt > now) return cached.value;
219
+
220
+ const target = resolvePrTarget(runner, branch);
221
+ if (!target.ok) return unavailable(target.reason);
222
+
223
+ const pr = {
224
+ number: target.target.number,
225
+ repo: target.target.repo,
226
+ url: target.target.url,
227
+ };
228
+ const inline = ghApiList(runner, `repos/${pr.repo}/pulls/${pr.number}/comments`);
229
+ if (!inline.ok) return unavailable(inline.reason, pr);
230
+
231
+ const issue = ghApiList(runner, `repos/${pr.repo}/issues/${pr.number}/comments`);
232
+ if (!issue.ok) return unavailable(issue.reason, pr);
233
+
234
+ const { threads, inline: inlineCount } = buildThreads(inline.items, repoRoot);
235
+ const issueComments = (issue.items as GhIssueComment[])
236
+ .map((raw) => toView(raw, repoRoot))
237
+ .filter((view): view is PrCommentView => view !== null)
238
+ .sort(
239
+ (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "") || a.id - b.id,
240
+ );
241
+
242
+ const value: PrCommentsResponse = {
243
+ available: true,
244
+ target: pr,
245
+ fetchedAt: systemClock(),
246
+ threads,
247
+ issueComments,
248
+ counts: {
249
+ threads: threads.length,
250
+ inline: inlineCount,
251
+ issue: issueComments.length,
252
+ },
253
+ };
254
+ cache.set(key, { expiresAt: now + CACHE_TTL_MS, value });
255
+ return value;
256
+ }