@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payconductor/houndsman",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Orquestrador de revisão de Pull Requests: classifica o diff, despacha watchdogs especializados por domínio e consolida o relatório.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,9 +26,15 @@ export class ServerCommand implements Command {
|
|
|
26
26
|
const parser = ArgParser.from(args);
|
|
27
27
|
const port = Number(parser.value("--port")) || 7331;
|
|
28
28
|
const host = parser.value("--host") ?? "127.0.0.1";
|
|
29
|
+
const editorPreference = parser.value("--editor");
|
|
29
30
|
|
|
30
31
|
const { startServer } = await import("../../server");
|
|
31
|
-
const { url } = startServer({
|
|
32
|
+
const { url } = startServer({
|
|
33
|
+
config: engine.config,
|
|
34
|
+
port,
|
|
35
|
+
host,
|
|
36
|
+
editorPreference,
|
|
37
|
+
});
|
|
32
38
|
context.out(`[houndsman] servidor em ${url}`);
|
|
33
39
|
context.out("[houndsman] Ctrl+C para encerrar.");
|
|
34
40
|
if (parser.has("--open")) openBrowser(url);
|
package/src/cli/help.ts
CHANGED
|
@@ -8,7 +8,7 @@ Comandos:
|
|
|
8
8
|
hunt Revisão nativa com IA (harness próprio): prepare + agentes + finalize
|
|
9
9
|
findings <sub> Triagem/correção dos findings (list · show · set · done · reset · sync · summary · batch · verify · submit · path)
|
|
10
10
|
status [ping] Mostra o status da run (ping renova o heartbeat da execução)
|
|
11
|
-
server Sobe a UI local de triagem (--port, --host, --open)
|
|
11
|
+
server Sobe a UI local de triagem (--port, --host, --open, --editor)
|
|
12
12
|
setup Cria a configuração inicial do projeto (config + skills)
|
|
13
13
|
update Sincroniza os arquivos auto-gerados (.houndsman/core)
|
|
14
14
|
doctor Valida a configuração, o ambiente e a credencial Jev
|
|
@@ -657,18 +657,23 @@ function splitList(raw: string | undefined): string[] {
|
|
|
657
657
|
.filter(Boolean);
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
-
/** Seleção do envio a partir das flags (`--severity`, `--
|
|
660
|
+
/** Seleção do envio a partir das flags (`--severity`, `--decision`, `--exec`). */
|
|
661
661
|
function submitSelection(parsed: ParsedArgs): SubmitSelection {
|
|
662
662
|
const severities = splitList(parsed.values["--severity"]).filter(
|
|
663
663
|
(value): value is Severity =>
|
|
664
664
|
(SEVERITY_ORDER as readonly string[]).includes(value),
|
|
665
665
|
);
|
|
666
|
+
const decisions = splitList(parsed.values["--decision"]).filter(
|
|
667
|
+
(value): value is FindingDecision =>
|
|
668
|
+
(FINDING_DECISIONS as readonly string[]).includes(value),
|
|
669
|
+
);
|
|
666
670
|
const execStates = splitList(parsed.values["--exec"]).filter(
|
|
667
671
|
(value): value is FindingExecState =>
|
|
668
672
|
(FINDING_EXEC_STATES as readonly string[]).includes(value),
|
|
669
673
|
);
|
|
670
674
|
return {
|
|
671
675
|
severities: severities.length > 0 ? severities : undefined,
|
|
676
|
+
decisions: decisions.length > 0 ? decisions : undefined,
|
|
672
677
|
execStates: execStates.length > 0 ? execStates : undefined,
|
|
673
678
|
includeSent: parsed.bools.has("--include-sent"),
|
|
674
679
|
};
|
package/src/core/defaults.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const DEFAULT_AST_MAX_IMPACT_FILES = 200;
|
|
|
29
29
|
export const DEFAULT_AI_PROVIDER = "deepseek" as const;
|
|
30
30
|
|
|
31
31
|
/** Modelo default da camada de IA. */
|
|
32
|
-
export const DEFAULT_AI_MODEL = "deepseek-v4
|
|
32
|
+
export const DEFAULT_AI_MODEL = "deepseek-v4-pro";
|
|
33
33
|
|
|
34
34
|
/** Concorrência default das chamadas de IA. */
|
|
35
35
|
export const DEFAULT_AI_CONCURRENCY = 2;
|
package/src/core/git-runner.ts
CHANGED
|
@@ -16,20 +16,32 @@ export type GitResult = {
|
|
|
16
16
|
/** Executa `git` e devolve o resultado cru. Injetável nos testes. */
|
|
17
17
|
export type GitRunner = (args: string[]) => GitResult;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Cria um runner do git com diretório de trabalho fixo.
|
|
21
|
+
*
|
|
22
|
+
* O servidor roda a partir da raiz do repositório, mas o checkout pode ser
|
|
23
|
+
* disparado de outra sessão — o `cwd` garante que os comandos atinjam o repo
|
|
24
|
+
* certo. Sem `cwd`, herda o diretório do processo (default).
|
|
25
|
+
*/
|
|
26
|
+
export function createGitRunner(cwd?: string): GitRunner {
|
|
27
|
+
return (args) => {
|
|
28
|
+
const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
|
|
29
|
+
cwd,
|
|
30
|
+
stdout: "pipe",
|
|
31
|
+
stderr: "pipe",
|
|
32
|
+
});
|
|
33
|
+
const code = result.exitCode ?? 1;
|
|
34
|
+
return {
|
|
35
|
+
ok: code === 0,
|
|
36
|
+
code,
|
|
37
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
38
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
39
|
+
};
|
|
31
40
|
};
|
|
32
|
-
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Runner padrão: `git -c core.quotepath=false <args>` a partir do cwd. */
|
|
44
|
+
export const defaultGitRunner: GitRunner = createGitRunner();
|
|
33
45
|
|
|
34
46
|
/** Executa e devolve o stdout; lança com o stderr quando o git falha. */
|
|
35
47
|
export function runGit(args: string[], runner: GitRunner = defaultGitRunner): string {
|
|
@@ -24,7 +24,12 @@ import {
|
|
|
24
24
|
type FindingsStateFile,
|
|
25
25
|
} from "./findings-state";
|
|
26
26
|
import { systemClock } from "./services/clock";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
defaultGhRunner,
|
|
29
|
+
describeGhError,
|
|
30
|
+
type GhResult,
|
|
31
|
+
type GhRunner,
|
|
32
|
+
} from "./services/gh-client";
|
|
28
33
|
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "./types";
|
|
29
34
|
|
|
30
35
|
export { type GhResult, type GhRunner, defaultGhRunner };
|
|
@@ -54,12 +59,7 @@ function repoFromUrl(url: string | undefined): string | null {
|
|
|
54
59
|
|
|
55
60
|
/** Mensagem curta para um erro do `gh`. */
|
|
56
61
|
function shortError(result: GhResult): string {
|
|
57
|
-
|
|
58
|
-
return "gh não instalado.";
|
|
59
|
-
}
|
|
60
|
-
const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
|
|
61
|
-
if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
|
|
62
|
-
return last || `gh saiu com código ${result.code}.`;
|
|
62
|
+
return describeGhError(result);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -118,6 +118,8 @@ export type SubmitCandidate = {
|
|
|
118
118
|
file: string;
|
|
119
119
|
line: number | null;
|
|
120
120
|
severity: Severity;
|
|
121
|
+
/** Decisão da triagem (`execute` = "a executar"/fila de correção). */
|
|
122
|
+
decision: FindingDecision;
|
|
121
123
|
execState: FindingExecState;
|
|
122
124
|
rule: string;
|
|
123
125
|
message: string;
|
|
@@ -159,6 +161,7 @@ export function selectSubmitCandidates(
|
|
|
159
161
|
file: finding.file,
|
|
160
162
|
line: finding.line,
|
|
161
163
|
severity: finding.severity,
|
|
164
|
+
decision: finding.decision,
|
|
162
165
|
execState: finding.execState,
|
|
163
166
|
rule: finding.rule,
|
|
164
167
|
message: finding.message,
|
|
@@ -10,29 +10,45 @@ export type GhResult = { ok: boolean; code: number; stdout: string; stderr: stri
|
|
|
10
10
|
/** Executa o `gh` e devolve o resultado cru (sem lançar). */
|
|
11
11
|
export type GhRunner = (args: string[]) => GhResult;
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
stdout: "pipe",
|
|
18
|
-
stderr: "pipe",
|
|
19
|
-
});
|
|
20
|
-
const code = result.exitCode ?? 1;
|
|
21
|
-
return {
|
|
22
|
-
ok: code === 0,
|
|
23
|
-
code,
|
|
24
|
-
stdout: new TextDecoder().decode(result.stdout).trim(),
|
|
25
|
-
stderr: new TextDecoder().decode(result.stderr).trim(),
|
|
26
|
-
};
|
|
27
|
-
} catch (error) {
|
|
28
|
-
return {
|
|
29
|
-
ok: false,
|
|
30
|
-
code: 127,
|
|
31
|
-
stdout: "",
|
|
32
|
-
stderr: error instanceof Error ? error.message : String(error),
|
|
33
|
-
};
|
|
13
|
+
/** Mensagem curta para um erro do `gh` (sem expor o stderr inteiro). */
|
|
14
|
+
export function describeGhError(result: GhResult): string {
|
|
15
|
+
if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
|
|
16
|
+
return "gh não instalado.";
|
|
34
17
|
}
|
|
35
|
-
|
|
18
|
+
const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
|
|
19
|
+
if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
|
|
20
|
+
return last || `gh saiu com código ${result.code}.`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Cria um runner do `gh` com diretório de trabalho fixo. */
|
|
24
|
+
export function createGhRunner(cwd?: string): GhRunner {
|
|
25
|
+
return (args) => {
|
|
26
|
+
try {
|
|
27
|
+
const result = Bun.spawnSync(["gh", ...args], {
|
|
28
|
+
cwd,
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
});
|
|
32
|
+
const code = result.exitCode ?? 1;
|
|
33
|
+
return {
|
|
34
|
+
ok: code === 0,
|
|
35
|
+
code,
|
|
36
|
+
stdout: new TextDecoder().decode(result.stdout).trim(),
|
|
37
|
+
stderr: new TextDecoder().decode(result.stderr).trim(),
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: 127,
|
|
43
|
+
stdout: "",
|
|
44
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
|
|
51
|
+
export const defaultGhRunner: GhRunner = createGhRunner();
|
|
36
52
|
|
|
37
53
|
/** Superfície OO sobre o runner injetável. */
|
|
38
54
|
export class GhClient {
|
|
@@ -7,18 +7,19 @@
|
|
|
7
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
8
8
|
import { ArtifactsPanel } from "./ArtifactsPanel";
|
|
9
9
|
import { AutoRefresh } from "./AutoRefresh";
|
|
10
|
+
import { CommentsPanel } from "./CommentsPanel";
|
|
10
11
|
import { Filters, type FiltersState } from "./Filters";
|
|
11
12
|
import { FindingsList } from "./FindingsList";
|
|
12
|
-
import { FileText, Layers, ListIcon, Refresh, Send } from "./icons";
|
|
13
|
+
import { FileText, Layers, ListIcon, MessageSquare, Refresh, Send } from "./icons";
|
|
13
14
|
import { ResumePanel } from "./ResumePanel";
|
|
14
|
-
import {
|
|
15
|
+
import { Sidebar } from "./Sidebar";
|
|
15
16
|
import { SubmitModal } from "./SubmitModal";
|
|
16
17
|
import { SummaryBar } from "./SummaryBar";
|
|
17
18
|
import { api, computeSummary } from "./utils/api";
|
|
18
19
|
import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./utils/constants";
|
|
19
|
-
import type { Finding, FindingsResponse, RunSummary } from "./utils/types";
|
|
20
|
+
import type { EditorContext, Finding, FindingsResponse, RunSummary } from "./utils/types";
|
|
20
21
|
|
|
21
|
-
type Tab = "resume" | "findings" | "artifacts";
|
|
22
|
+
type Tab = "resume" | "findings" | "artifacts" | "comments";
|
|
22
23
|
|
|
23
24
|
const TABS: { id: Tab; label: string; hint: string }[] = [
|
|
24
25
|
{
|
|
@@ -36,8 +37,20 @@ const TABS: { id: Tab; label: string; hint: string }[] = [
|
|
|
36
37
|
label: "Artefatos",
|
|
37
38
|
hint: "Arquivos brutos gerados pela revisão (manifesto, classificação, relatório…)",
|
|
38
39
|
},
|
|
40
|
+
{
|
|
41
|
+
id: "comments",
|
|
42
|
+
label: "Comentários",
|
|
43
|
+
hint: "Comentários da PR: threads de revisão e comentários gerais (carregados ao abrir a aba)",
|
|
44
|
+
},
|
|
39
45
|
];
|
|
40
46
|
|
|
47
|
+
function TabIcon({ id }: { id: Tab }) {
|
|
48
|
+
if (id === "resume") return <FileText size={15} />;
|
|
49
|
+
if (id === "findings") return <ListIcon size={15} />;
|
|
50
|
+
if (id === "artifacts") return <Layers size={15} />;
|
|
51
|
+
return <MessageSquare size={15} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function App() {
|
|
42
55
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
43
56
|
const [slug, setSlug] = useState<string | null>(null);
|
|
@@ -54,6 +67,8 @@ export function App() {
|
|
|
54
67
|
const [refreshing, setRefreshing] = useState(false);
|
|
55
68
|
const [syncing, setSyncing] = useState(false);
|
|
56
69
|
const [submitOpen, setSubmitOpen] = useState(false);
|
|
70
|
+
/** Editor detectado pelo servidor — base dos links de código. */
|
|
71
|
+
const [editor, setEditor] = useState<EditorContext>({ kind: "vscode" });
|
|
57
72
|
/** Refs espelhando estado volátil — o polling não deve recriar o intervalo. */
|
|
58
73
|
const lastSyncRef = useRef(lastSync);
|
|
59
74
|
const slugRef = useRef<string | null>(null);
|
|
@@ -116,6 +131,14 @@ export function App() {
|
|
|
116
131
|
reloadRuns();
|
|
117
132
|
}, [reloadRuns]);
|
|
118
133
|
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
api<EditorContext>("/api/editor")
|
|
136
|
+
.then(setEditor)
|
|
137
|
+
.catch(() => {
|
|
138
|
+
// fail-open: mantém o fallback VS Code.
|
|
139
|
+
});
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
119
142
|
useEffect(() => {
|
|
120
143
|
if (slug) loadFindings(slug);
|
|
121
144
|
}, [slug, loadFindings]);
|
|
@@ -194,7 +217,12 @@ export function App() {
|
|
|
194
217
|
|
|
195
218
|
return (
|
|
196
219
|
<div class="app">
|
|
197
|
-
<
|
|
220
|
+
<Sidebar
|
|
221
|
+
runs={runs}
|
|
222
|
+
slug={slug}
|
|
223
|
+
onSelect={setSlug}
|
|
224
|
+
onRefreshRuns={() => void reloadRuns()}
|
|
225
|
+
/>
|
|
198
226
|
<main class="main">
|
|
199
227
|
<header class="topbar">
|
|
200
228
|
<div class="segmented">
|
|
@@ -206,13 +234,7 @@ export function App() {
|
|
|
206
234
|
class={`seg-btn${tab === item.id ? " active" : ""}`}
|
|
207
235
|
onClick={() => setTab(item.id)}
|
|
208
236
|
>
|
|
209
|
-
{item.id
|
|
210
|
-
<FileText size={15} />
|
|
211
|
-
) : item.id === "findings" ? (
|
|
212
|
-
<ListIcon size={15} />
|
|
213
|
-
) : (
|
|
214
|
-
<Layers size={15} />
|
|
215
|
-
)}
|
|
237
|
+
<TabIcon id={item.id} />
|
|
216
238
|
{item.label}
|
|
217
239
|
</button>
|
|
218
240
|
))}
|
|
@@ -278,11 +300,15 @@ export function App() {
|
|
|
278
300
|
<FindingsList
|
|
279
301
|
slug={slug}
|
|
280
302
|
findings={findings}
|
|
303
|
+
editor={editor}
|
|
281
304
|
onUpdated={onUpdated}
|
|
282
305
|
/>
|
|
283
306
|
</>
|
|
284
307
|
)}
|
|
285
308
|
{slug && tab === "artifacts" && <ArtifactsPanel slug={slug} />}
|
|
309
|
+
{slug && tab === "comments" && (
|
|
310
|
+
<CommentsPanel slug={slug} editor={editor} />
|
|
311
|
+
)}
|
|
286
312
|
{!slug && !error && <p class="empty">Nenhuma execução para triar.</p>}
|
|
287
313
|
</div>
|
|
288
314
|
{slug && (
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aba "Comentários": threads de revisão e comentários gerais da PR da run.
|
|
3
|
+
*
|
|
4
|
+
* O componente só é montado quando a aba é acessada — o carregamento (e a
|
|
5
|
+
* chamada ao `gh`) acontece no mount, nunca no carregamento da página.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
|
9
|
+
import { Clock, CodeIcon, ExternalLink, GitHub, MessageSquare, Refresh } from "./icons";
|
|
10
|
+
import { MarkdownText } from "./MarkdownText";
|
|
11
|
+
import { api } from "./utils/api";
|
|
12
|
+
import { editorFileLink, editorLabel, formatRunTime } from "./utils/format";
|
|
13
|
+
import type {
|
|
14
|
+
EditorContext,
|
|
15
|
+
PrCommentsResponse,
|
|
16
|
+
PrCommentThread,
|
|
17
|
+
PrCommentView,
|
|
18
|
+
} from "./utils/types";
|
|
19
|
+
|
|
20
|
+
function CommentCard({
|
|
21
|
+
comment,
|
|
22
|
+
editor,
|
|
23
|
+
reply,
|
|
24
|
+
}: {
|
|
25
|
+
comment: PrCommentView;
|
|
26
|
+
editor: EditorContext;
|
|
27
|
+
reply: boolean;
|
|
28
|
+
}) {
|
|
29
|
+
const location = comment.path
|
|
30
|
+
? comment.line
|
|
31
|
+
? `${comment.path}:${comment.line}`
|
|
32
|
+
: comment.path
|
|
33
|
+
: null;
|
|
34
|
+
const href = comment.absFile
|
|
35
|
+
? editorFileLink(comment.absFile, comment.line, editor)
|
|
36
|
+
: null;
|
|
37
|
+
return (
|
|
38
|
+
<article class={`pr-comment${reply ? " reply" : ""}`}>
|
|
39
|
+
<header class="pr-comment-head">
|
|
40
|
+
<span class="pr-comment-author">{comment.author}</span>
|
|
41
|
+
{comment.authorType === "Bot" && <span class="tag">bot</span>}
|
|
42
|
+
<span class="run-when">
|
|
43
|
+
<Clock size={12} />
|
|
44
|
+
{formatRunTime(comment.createdAt)}
|
|
45
|
+
</span>
|
|
46
|
+
<span class="spacer" />
|
|
47
|
+
{comment.url && (
|
|
48
|
+
<a
|
|
49
|
+
class="pr-comment-link"
|
|
50
|
+
href={comment.url}
|
|
51
|
+
target="_blank"
|
|
52
|
+
rel="noreferrer"
|
|
53
|
+
title="Abrir o comentário no GitHub"
|
|
54
|
+
>
|
|
55
|
+
<GitHub size={12} /> GitHub
|
|
56
|
+
</a>
|
|
57
|
+
)}
|
|
58
|
+
</header>
|
|
59
|
+
<div class="pr-comment-body">
|
|
60
|
+
<MarkdownText text={comment.body} />
|
|
61
|
+
</div>
|
|
62
|
+
{location &&
|
|
63
|
+
(href ? (
|
|
64
|
+
<a
|
|
65
|
+
class="loc"
|
|
66
|
+
href={href}
|
|
67
|
+
title={`Abrir ${location} no ${editorLabel(editor)}`}
|
|
68
|
+
>
|
|
69
|
+
<CodeIcon size={13} />
|
|
70
|
+
{location}
|
|
71
|
+
<ExternalLink size={12} class="ext" />
|
|
72
|
+
</a>
|
|
73
|
+
) : (
|
|
74
|
+
<code class="loc static">
|
|
75
|
+
<CodeIcon size={13} />
|
|
76
|
+
{location}
|
|
77
|
+
</code>
|
|
78
|
+
))}
|
|
79
|
+
</article>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ThreadCard({
|
|
84
|
+
thread,
|
|
85
|
+
editor,
|
|
86
|
+
}: {
|
|
87
|
+
thread: PrCommentThread;
|
|
88
|
+
editor: EditorContext;
|
|
89
|
+
}) {
|
|
90
|
+
const location = thread.line
|
|
91
|
+
? `${thread.path}:${thread.line}`
|
|
92
|
+
: thread.path || "arquivo";
|
|
93
|
+
const href = thread.absFile
|
|
94
|
+
? editorFileLink(thread.absFile, thread.line, editor)
|
|
95
|
+
: null;
|
|
96
|
+
return (
|
|
97
|
+
<section class="comment-thread">
|
|
98
|
+
<header class="comment-thread-head">
|
|
99
|
+
<CodeIcon size={13} />
|
|
100
|
+
<span class="comment-file">{location}</span>
|
|
101
|
+
<span class="spacer" />
|
|
102
|
+
{href && (
|
|
103
|
+
<a
|
|
104
|
+
class="icon-btn"
|
|
105
|
+
href={href}
|
|
106
|
+
title={`Abrir ${location} no ${editorLabel(editor)}`}
|
|
107
|
+
>
|
|
108
|
+
<ExternalLink size={13} />
|
|
109
|
+
</a>
|
|
110
|
+
)}
|
|
111
|
+
<span class="count">{thread.comments.length}</span>
|
|
112
|
+
</header>
|
|
113
|
+
{thread.comments.map((comment, index) => (
|
|
114
|
+
<CommentCard
|
|
115
|
+
key={comment.id}
|
|
116
|
+
comment={comment}
|
|
117
|
+
editor={editor}
|
|
118
|
+
reply={index > 0}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</section>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function CommentsPanel({ slug, editor }: { slug: string; editor: EditorContext }) {
|
|
126
|
+
const [data, setData] = useState<PrCommentsResponse | null>(null);
|
|
127
|
+
const [loading, setLoading] = useState(true);
|
|
128
|
+
const [error, setError] = useState("");
|
|
129
|
+
|
|
130
|
+
const load = useCallback(
|
|
131
|
+
async (refresh = false) => {
|
|
132
|
+
setLoading(true);
|
|
133
|
+
setError("");
|
|
134
|
+
try {
|
|
135
|
+
const result = await api<PrCommentsResponse>(
|
|
136
|
+
`/api/runs/${slug}/comments${refresh ? "?refresh=1" : ""}`,
|
|
137
|
+
);
|
|
138
|
+
setData(result);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
setError(String(err));
|
|
141
|
+
} finally {
|
|
142
|
+
setLoading(false);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
[slug],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
setData(null);
|
|
150
|
+
void load();
|
|
151
|
+
}, [load]);
|
|
152
|
+
|
|
153
|
+
const groups = useMemo(() => {
|
|
154
|
+
const map = new Map<string, PrCommentThread[]>();
|
|
155
|
+
for (const thread of data?.threads ?? []) {
|
|
156
|
+
const key = thread.path || "(sem arquivo)";
|
|
157
|
+
const list = map.get(key) ?? [];
|
|
158
|
+
list.push(thread);
|
|
159
|
+
map.set(key, list);
|
|
160
|
+
}
|
|
161
|
+
return [...map.entries()];
|
|
162
|
+
}, [data]);
|
|
163
|
+
|
|
164
|
+
const empty =
|
|
165
|
+
data?.available && data.threads.length === 0 && data.issueComments.length === 0;
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div class="comments">
|
|
169
|
+
<div class="comments-toolbar">
|
|
170
|
+
<div class="comments-counts">
|
|
171
|
+
<MessageSquare size={14} />
|
|
172
|
+
{data?.available ? (
|
|
173
|
+
<>
|
|
174
|
+
<span>
|
|
175
|
+
<b>{data.counts.threads}</b> threads
|
|
176
|
+
</span>
|
|
177
|
+
<span>
|
|
178
|
+
<b>{data.counts.inline}</b> inline
|
|
179
|
+
</span>
|
|
180
|
+
<span>
|
|
181
|
+
<b>{data.counts.issue}</b> gerais
|
|
182
|
+
</span>
|
|
183
|
+
</>
|
|
184
|
+
) : (
|
|
185
|
+
<span>Comentários da PR</span>
|
|
186
|
+
)}
|
|
187
|
+
{data?.target?.url && (
|
|
188
|
+
<a
|
|
189
|
+
class="pr-comment-link"
|
|
190
|
+
href={data.target.url}
|
|
191
|
+
target="_blank"
|
|
192
|
+
rel="noreferrer"
|
|
193
|
+
>
|
|
194
|
+
<GitHub size={12} /> PR #{data.target.number}
|
|
195
|
+
</a>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
class="btn"
|
|
201
|
+
disabled={loading}
|
|
202
|
+
title="Recarrega os comentários ignorando o cache de 60s"
|
|
203
|
+
onClick={() => void load(true)}
|
|
204
|
+
>
|
|
205
|
+
<Refresh size={14} class={loading ? "icon spin" : undefined} />
|
|
206
|
+
Atualizar
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{loading && !data && <p class="empty">Carregando comentários da PR…</p>}
|
|
211
|
+
{error && <p class="error">{error}</p>}
|
|
212
|
+
{data && !data.available && (
|
|
213
|
+
<p class="empty">
|
|
214
|
+
Comentários indisponíveis
|
|
215
|
+
{data.reason ? `: ${data.reason}` : "."}
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
{empty && <p class="empty">Nenhum comentário nesta PR.</p>}
|
|
219
|
+
|
|
220
|
+
{data?.available && groups.length > 0 && (
|
|
221
|
+
<section class="comment-group">
|
|
222
|
+
<h2 class="comment-group-title">Comentários de revisão</h2>
|
|
223
|
+
{groups.map(([path, threads]) => (
|
|
224
|
+
<div class="comment-file-group" key={path}>
|
|
225
|
+
<p class="comment-group-label">
|
|
226
|
+
{path} <span class="count">{threads.length}</span>
|
|
227
|
+
</p>
|
|
228
|
+
{threads.map((thread) => (
|
|
229
|
+
<ThreadCard
|
|
230
|
+
key={thread.id}
|
|
231
|
+
thread={thread}
|
|
232
|
+
editor={editor}
|
|
233
|
+
/>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</section>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{data?.available && data.issueComments.length > 0 && (
|
|
241
|
+
<section class="comment-group">
|
|
242
|
+
<h2 class="comment-group-title">Comentários gerais</h2>
|
|
243
|
+
{data.issueComments.map((comment) => (
|
|
244
|
+
<CommentCard
|
|
245
|
+
key={comment.id}
|
|
246
|
+
comment={comment}
|
|
247
|
+
editor={editor}
|
|
248
|
+
reply={false}
|
|
249
|
+
/>
|
|
250
|
+
))}
|
|
251
|
+
</section>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -21,8 +21,8 @@ import { MarkdownText } from "./MarkdownText";
|
|
|
21
21
|
import { api } from "./utils/api";
|
|
22
22
|
import { highlightCode, languageFromFile } from "./utils/code-highlight";
|
|
23
23
|
import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS } from "./utils/constants";
|
|
24
|
-
import {
|
|
25
|
-
import type { Finding } from "./utils/types";
|
|
24
|
+
import { editorFileLink, editorLabel } from "./utils/format";
|
|
25
|
+
import type { EditorContext, Finding } from "./utils/types";
|
|
26
26
|
|
|
27
27
|
/** Debounce do auto-save do comentário (ms). */
|
|
28
28
|
const AUTOSAVE_MS = 700;
|
|
@@ -32,10 +32,12 @@ const SAVED_VISIBLE_MS = 1800;
|
|
|
32
32
|
export function FindingCard({
|
|
33
33
|
slug,
|
|
34
34
|
finding,
|
|
35
|
+
editor,
|
|
35
36
|
onUpdated,
|
|
36
37
|
}: {
|
|
37
38
|
slug: string;
|
|
38
39
|
finding: Finding;
|
|
40
|
+
editor: EditorContext;
|
|
39
41
|
onUpdated: (finding: Finding) => void;
|
|
40
42
|
}) {
|
|
41
43
|
const isDone = finding.execState === "done";
|
|
@@ -104,7 +106,9 @@ export function FindingCard({
|
|
|
104
106
|
}, [comment, patch]);
|
|
105
107
|
|
|
106
108
|
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
107
|
-
const href = finding.absFile
|
|
109
|
+
const href = finding.absFile
|
|
110
|
+
? editorFileLink(finding.absFile, finding.line, editor)
|
|
111
|
+
: null;
|
|
108
112
|
const highlighted = useMemo(
|
|
109
113
|
() =>
|
|
110
114
|
finding.evidence
|
|
@@ -132,7 +136,11 @@ export function FindingCard({
|
|
|
132
136
|
</button>
|
|
133
137
|
<span class="sev-badge">{SEVERITY_LABELS[finding.severity]}</span>
|
|
134
138
|
{href ? (
|
|
135
|
-
<a
|
|
139
|
+
<a
|
|
140
|
+
class="loc"
|
|
141
|
+
href={href}
|
|
142
|
+
title={`Abrir ${location} no ${editorLabel(editor)}`}
|
|
143
|
+
>
|
|
136
144
|
<CodeIcon size={13} />
|
|
137
145
|
{location}
|
|
138
146
|
<ExternalLink size={12} class="ext" />
|
|
@@ -6,15 +6,17 @@ import { useMemo } from "preact/hooks";
|
|
|
6
6
|
import { FindingCard } from "./FindingCard";
|
|
7
7
|
import { Alert } from "./icons";
|
|
8
8
|
import { SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
|
|
9
|
-
import type { Finding, Severity } from "./utils/types";
|
|
9
|
+
import type { EditorContext, Finding, Severity } from "./utils/types";
|
|
10
10
|
|
|
11
11
|
export function FindingsList({
|
|
12
12
|
slug,
|
|
13
13
|
findings,
|
|
14
|
+
editor,
|
|
14
15
|
onUpdated,
|
|
15
16
|
}: {
|
|
16
17
|
slug: string;
|
|
17
18
|
findings: Finding[];
|
|
19
|
+
editor: EditorContext;
|
|
18
20
|
onUpdated: (finding: Finding) => void;
|
|
19
21
|
}) {
|
|
20
22
|
const grouped = useMemo(() => {
|
|
@@ -45,6 +47,7 @@ export function FindingsList({
|
|
|
45
47
|
key={finding.id}
|
|
46
48
|
slug={slug}
|
|
47
49
|
finding={finding}
|
|
50
|
+
editor={editor}
|
|
48
51
|
onUpdated={onUpdated}
|
|
49
52
|
/>
|
|
50
53
|
))}
|