@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.
- package/package.json +1 -1
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/help.ts +1 -1
- package/src/core/commands/findings.ts +6 -1
- package/src/core/defaults.ts +1 -1
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/services/gh-client.ts +38 -22
- package/src/server/client/App.tsx +38 -12
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/Sidebar.tsx +397 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +19 -0
- package/src/server/client/index.tsx +1 -1
- package/src/server/client/utils/constants.ts +2 -0
- package/src/server/client/utils/format.ts +47 -2
- package/src/server/client/utils/types.ts +82 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +112 -3
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +202 -0
- package/src/server/run-repository.ts +33 -1
- package/src/tests/ai-core.test.ts +2 -2
- package/src/tests/cli.test.ts +33 -0
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +295 -2
- package/src/server/client/RunList.tsx +0 -75
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
204
|
+
repo,
|
|
205
|
+
this.ghRunner,
|
|
169
206
|
);
|
|
170
|
-
this.
|
|
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
|
+
}
|