@payconductor/houndsman 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/cli/commands/command.ts +10 -1
- package/src/cli/commands/context-command.ts +40 -2
- package/src/cli/commands/hunt-command.ts +28 -2
- package/src/cli/commands/review-command.ts +30 -2
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/commands/worktree-command.ts +118 -0
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +14 -4
- package/src/core/ast/service.ts +7 -2
- package/src/core/brief.ts +1 -0
- package/src/core/commands/context.ts +9 -1
- package/src/core/commands/findings.ts +17 -6
- package/src/core/commands/review.ts +13 -0
- package/src/core/commands/scaffold.ts +4 -2
- package/src/core/config.ts +13 -0
- package/src/core/defaults.ts +8 -1
- package/src/core/engine/houndsman-engine.ts +26 -3
- package/src/core/engine/review-scope.ts +17 -22
- package/src/core/engine/review-session.ts +25 -2
- package/src/core/engine/review-workspace.ts +204 -0
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/jev/jev-detector.ts +10 -2
- package/src/core/orchestrator.ts +62 -13
- package/src/core/project-config.ts +12 -0
- package/src/core/services/gh-client.ts +38 -22
- package/src/core/services/git-repository.ts +46 -0
- package/src/core/services/pr-resolver.ts +147 -0
- package/src/core/services/worktree-manager.ts +269 -0
- package/src/core/storage/file/file-storage.ts +14 -1
- package/src/core/storage/paths.ts +30 -1
- package/src/core/storage/sqlite/mappers.ts +4 -0
- package/src/core/storage/sqlite/schema.ts +13 -3
- package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
- package/src/core/storage/types.ts +12 -0
- package/src/core/storage/work-area.ts +20 -1
- package/src/core/types.ts +2 -0
- package/src/harness/hunt/hunt-options.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +7 -20
- package/src/index.ts +31 -0
- package/src/server/client/App.tsx +86 -22
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/DeleteRunModal.tsx +163 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/ResumePanel.tsx +8 -8
- package/src/server/client/Sidebar.tsx +452 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +29 -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 +108 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +205 -3
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +213 -0
- package/src/server/run-repository.ts +136 -3
- package/src/skill/README.md +11 -3
- package/src/skill/SKILL.md +44 -10
- package/src/skill/hunt.md +33 -7
- package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
- package/src/tests/ai-core.test.ts +2 -2
- package/src/tests/cli.test.ts +35 -1
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +333 -2
- package/src/tests/worktree.test.ts +369 -0
- package/src/server/client/RunList.tsx +0 -75
package/src/core/orchestrator.ts
CHANGED
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
* review.<watchdog>.md, context e context-summary.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
20
21
|
import type { AiClient } from "./ai/ai-client";
|
|
21
22
|
import { AiBriefEnricher } from "./ai/features/brief-enricher";
|
|
22
23
|
import { AiClassifier } from "./ai/features/classifier";
|
|
23
24
|
import { AiContextSummarizer } from "./ai/features/context-summarizer";
|
|
24
25
|
import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
|
|
25
26
|
import type { BriefPlan } from "./ai/schemas";
|
|
27
|
+
import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
|
|
26
28
|
import type { AstService } from "./ast/service";
|
|
27
29
|
import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
|
|
28
30
|
import {
|
|
@@ -39,7 +41,6 @@ import { ReviewScopeResolver, type RunClassification } from "./engine/review-sco
|
|
|
39
41
|
import { ENV_NAMES } from "./env";
|
|
40
42
|
import type { BudgetSelection, ClassificationResult } from "./file-classifier";
|
|
41
43
|
import { buildStateFromFindings, fileReader } from "./findings-state";
|
|
42
|
-
import { resolveSha } from "./git-revision";
|
|
43
44
|
import { syncResolvedThreads } from "./github-review";
|
|
44
45
|
import {
|
|
45
46
|
type JevCandidate,
|
|
@@ -56,7 +57,6 @@ import {
|
|
|
56
57
|
REVIEW_FILE,
|
|
57
58
|
REVIEW_RESUME_FILE,
|
|
58
59
|
} from "./paths";
|
|
59
|
-
import { currentBranch } from "./pr-context";
|
|
60
60
|
import { ProjectConfig } from "./project-config";
|
|
61
61
|
import { BriefRenderer } from "./renderers/brief-renderer";
|
|
62
62
|
import {
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
} from "./report-builder";
|
|
77
77
|
import { writeRunStatus } from "./run-status";
|
|
78
78
|
import { systemClock } from "./services/clock";
|
|
79
|
+
import { GitRepository } from "./services/git-repository";
|
|
79
80
|
import type { ReviewRecord, StorageProvider } from "./storage/types";
|
|
80
81
|
import { ReviewWorkArea } from "./storage/work-area";
|
|
81
82
|
import type { WatchdogMeta } from "./types";
|
|
@@ -95,6 +96,12 @@ export type ReviewOptions = {
|
|
|
95
96
|
full: boolean;
|
|
96
97
|
/** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
|
|
97
98
|
noAi: boolean;
|
|
99
|
+
/** PR de origem (resolvida via `--pr`). */
|
|
100
|
+
pr?: number;
|
|
101
|
+
/** Worktree informado via flag (`--worktree`). */
|
|
102
|
+
worktree?: string;
|
|
103
|
+
/** Worktree resolvido (absoluto; ausente = clone principal). */
|
|
104
|
+
worktreePath?: string;
|
|
98
105
|
};
|
|
99
106
|
|
|
100
107
|
/** Valores default das opções (o CLI sobrepõe com as flags). */
|
|
@@ -112,19 +119,49 @@ export function defaultOptions(): ReviewOptions {
|
|
|
112
119
|
};
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
/**
|
|
122
|
+
/** Workspace onde os arquivos da review são lidos. */
|
|
123
|
+
export type ReviewWorkspaceContext = {
|
|
124
|
+
/** Raiz operacional (worktree ou clone principal). */
|
|
125
|
+
root?: string;
|
|
126
|
+
/** Raiz do clone principal (identidade/config). */
|
|
127
|
+
mainRoot?: string;
|
|
128
|
+
git?: GitRepository;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Contexto de uma run: storage + review + staging + raiz operacional. */
|
|
116
132
|
export type ReviewContext = {
|
|
117
133
|
storage: StorageProvider;
|
|
118
134
|
review: ReviewRecord;
|
|
119
135
|
work: ReviewWorkArea;
|
|
136
|
+
root: string;
|
|
137
|
+
mainRoot: string;
|
|
138
|
+
git: GitRepository;
|
|
120
139
|
};
|
|
121
140
|
|
|
122
141
|
/** Cria o contexto de uma review existente. */
|
|
123
142
|
export function reviewContext(
|
|
124
143
|
storage: StorageProvider,
|
|
125
144
|
review: ReviewRecord,
|
|
145
|
+
workspace: ReviewWorkspaceContext = {},
|
|
126
146
|
): ReviewContext {
|
|
127
|
-
|
|
147
|
+
const root = workspace.root ?? process.cwd();
|
|
148
|
+
return {
|
|
149
|
+
storage,
|
|
150
|
+
review,
|
|
151
|
+
work: new ReviewWorkArea(review),
|
|
152
|
+
root,
|
|
153
|
+
mainRoot: workspace.mainRoot ?? root,
|
|
154
|
+
git: workspace.git ?? new GitRepository(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Schema do output: relativo à raiz da review quando a camada existe; no
|
|
160
|
+
* worktree sem camada, cai para o asset embarcado (caminho absoluto legível).
|
|
161
|
+
*/
|
|
162
|
+
function outputFormatPathFor(ctx: ReviewContext): string {
|
|
163
|
+
if (existsSync(join(ctx.root, OUTPUT_FORMAT_PATH))) return OUTPUT_FORMAT_PATH;
|
|
164
|
+
return ctx.review.worktreePath ? assetPath(OUTPUT_FORMAT_FILE) : OUTPUT_FORMAT_PATH;
|
|
128
165
|
}
|
|
129
166
|
|
|
130
167
|
// ---------------------------------------------------------------------------
|
|
@@ -225,7 +262,8 @@ function writeBriefs(
|
|
|
225
262
|
deleted: classification.deleted,
|
|
226
263
|
branch,
|
|
227
264
|
outputPath: work.findingFile(id),
|
|
228
|
-
outputFormatPath:
|
|
265
|
+
outputFormatPath: outputFormatPathFor(ctx),
|
|
266
|
+
worktree: ctx.review.worktreePath,
|
|
229
267
|
docs,
|
|
230
268
|
context,
|
|
231
269
|
candidates: candidates.filter((c) => c.watchdogId === id),
|
|
@@ -277,7 +315,7 @@ async function resolveCandidates(
|
|
|
277
315
|
const def = findWatchdog(config, id);
|
|
278
316
|
if (!def) continue;
|
|
279
317
|
const inputs = files.slice(0, 5).flatMap((file) => {
|
|
280
|
-
const snippet = readSnippet(file);
|
|
318
|
+
const snippet = readSnippet(ctx.root, file);
|
|
281
319
|
return snippet ? [{ file, snippet }] : [];
|
|
282
320
|
});
|
|
283
321
|
candidates.push(
|
|
@@ -314,6 +352,7 @@ async function resolveCandidates(
|
|
|
314
352
|
const detector = new JevDetector(
|
|
315
353
|
client,
|
|
316
354
|
config.watchdogs as Record<string, WatchdogMeta>,
|
|
355
|
+
ctx.root,
|
|
317
356
|
);
|
|
318
357
|
const result = await detector.detect(classification, opts.base, opts.head, {
|
|
319
358
|
diff,
|
|
@@ -377,9 +416,10 @@ function budgetMessage(
|
|
|
377
416
|
}
|
|
378
417
|
|
|
379
418
|
/** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
|
|
380
|
-
function readSnippet(file: string): string | null {
|
|
419
|
+
function readSnippet(root: string, file: string): string | null {
|
|
381
420
|
try {
|
|
382
|
-
|
|
421
|
+
const path = join(root, file);
|
|
422
|
+
return readFileSync(path, "utf8").split("\n").slice(0, 80).join("\n");
|
|
383
423
|
} catch {
|
|
384
424
|
return null;
|
|
385
425
|
}
|
|
@@ -507,12 +547,13 @@ export async function prepare(
|
|
|
507
547
|
ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
|
|
508
548
|
|
|
509
549
|
// Marca o commit revisado e decide entre completo, incremental ou no-op.
|
|
510
|
-
const headSha = resolveSha(opts.head);
|
|
550
|
+
const headSha = ctx.git.resolveSha(opts.head);
|
|
511
551
|
const previous = ctx.storage.readFindings(ctx.review.id);
|
|
512
552
|
const scope = await new ReviewScopeResolver(
|
|
513
553
|
opts,
|
|
514
554
|
config,
|
|
515
555
|
opts.noAst ? undefined : ast,
|
|
556
|
+
ctx.git,
|
|
516
557
|
).resolve(previous, headSha);
|
|
517
558
|
|
|
518
559
|
// Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
|
|
@@ -937,7 +978,7 @@ export function finalize(
|
|
|
937
978
|
// (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
|
|
938
979
|
const state = buildStateFromFindings(merged, {
|
|
939
980
|
branch,
|
|
940
|
-
readFile: fileReader(
|
|
981
|
+
readFile: fileReader(ctx.root),
|
|
941
982
|
previous: ctx.storage.readFindings(ctx.review.id),
|
|
942
983
|
reviewedPaths: new Set([
|
|
943
984
|
...reports.flatMap((report) => report.reviewedFiles),
|
|
@@ -1042,6 +1083,14 @@ export function finalize(
|
|
|
1042
1083
|
}
|
|
1043
1084
|
|
|
1044
1085
|
/** Resolve a branch (opção explícita ou a atual do git). */
|
|
1045
|
-
export function resolveBranch(
|
|
1046
|
-
|
|
1086
|
+
export function resolveBranch(
|
|
1087
|
+
opts: ReviewOptions,
|
|
1088
|
+
git: GitRepository = new GitRepository(),
|
|
1089
|
+
): string {
|
|
1090
|
+
if (opts.branch) return opts.branch;
|
|
1091
|
+
const branch = git.currentBranch();
|
|
1092
|
+
if (!branch) {
|
|
1093
|
+
throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
|
|
1094
|
+
}
|
|
1095
|
+
return branch;
|
|
1047
1096
|
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
HoundsmanAst,
|
|
14
14
|
HoundsmanConfig,
|
|
15
15
|
HoundsmanStorage,
|
|
16
|
+
HoundsmanWorktrees,
|
|
16
17
|
} from "./config";
|
|
17
18
|
import {
|
|
18
19
|
DEFAULT_AI_CONCURRENCY,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
DEFAULT_AST_MAX_IMPACT_FILES,
|
|
28
29
|
DEFAULT_JEV_CONCURRENCY,
|
|
29
30
|
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
31
|
+
DEFAULT_WORKTREES_LOCATION,
|
|
30
32
|
} from "./defaults";
|
|
31
33
|
import { WatchdogRegistry } from "./watchdog-registry";
|
|
32
34
|
|
|
@@ -48,6 +50,16 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
48
50
|
return this.raw.storage ?? {};
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** Config de worktrees (objeto vazio quando ausente). */
|
|
54
|
+
get worktrees(): HoundsmanWorktrees {
|
|
55
|
+
return this.raw.worktrees ?? {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Localização dos worktrees resolvida (default: workspace). */
|
|
59
|
+
get worktreesLocation(): string {
|
|
60
|
+
return this.worktrees.location?.trim() || DEFAULT_WORKTREES_LOCATION;
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/** Config do AST (objeto vazio quando ausente). */
|
|
52
64
|
get ast(): HoundsmanAst {
|
|
53
65
|
return this.raw.ast ?? {};
|
|
@@ -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 {
|
|
@@ -75,6 +75,52 @@ export class GitRepository {
|
|
|
75
75
|
isAncestor(ancestor: string, descendant: string): boolean {
|
|
76
76
|
return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
77
77
|
}
|
|
78
|
+
/** SHA do merge-base entre duas refs; `null` quando não há ancestral comum. */
|
|
79
|
+
mergeBase(base: string, head: string): string | null {
|
|
80
|
+
const result = this.tryRun(["merge-base", base, head]);
|
|
81
|
+
const sha = result.ok ? result.stdout.trim() : "";
|
|
82
|
+
return sha.length > 0 ? sha : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Diretório git comum (`.git`) em caminho absoluto. Em um worktree linkado
|
|
87
|
+
* aponta para o `.git` do clone principal — é como descobrimos a raiz real.
|
|
88
|
+
*/
|
|
89
|
+
gitCommonDir(): string | null {
|
|
90
|
+
const result = this.tryRun([
|
|
91
|
+
"rev-parse",
|
|
92
|
+
"--path-format=absolute",
|
|
93
|
+
"--git-common-dir",
|
|
94
|
+
]);
|
|
95
|
+
const dir = result.ok ? result.stdout.trim() : "";
|
|
96
|
+
return dir.length > 0 ? dir : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Saída crua de `git worktree list --porcelain` (vazia em erro). */
|
|
100
|
+
worktreeListRaw(): string {
|
|
101
|
+
const result = this.tryRun(["worktree", "list", "--porcelain"]);
|
|
102
|
+
return result.ok ? result.stdout : "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Cria um worktree detached apontando para `ref` (lança em falha). */
|
|
106
|
+
addWorktree(path: string, ref: string): string {
|
|
107
|
+
return this.run(["worktree", "add", "--detach", path, ref]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove um worktree; nunca lança (devolve o resultado para o chamador). */
|
|
111
|
+
removeWorktree(path: string, force = false): GitResult {
|
|
112
|
+
return this.tryRun(["worktree", "remove", ...(force ? ["--force"] : []), path]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Remove metadados de worktrees cujo diretório sumiu. */
|
|
116
|
+
pruneWorktrees(): GitResult {
|
|
117
|
+
return this.tryRun(["worktree", "prune"]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `git fetch --no-tags <remote> <refspecs...>` (nunca lança). */
|
|
121
|
+
fetch(remote: string, refspecs: readonly string[]): GitResult {
|
|
122
|
+
return this.tryRun(["fetch", "--no-tags", remote, ...refspecs]);
|
|
123
|
+
}
|
|
78
124
|
|
|
79
125
|
/** Conteúdo de um arquivo num commit; `null` se ausente. */
|
|
80
126
|
readFileAt(sha: string, path: string): string | null {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolução de uma PR para revisão (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Usa `gh pr view` para descobrir head/base e garante os commits localmente.
|
|
5
|
+
* O head é buscado em `refs/pull/<n>/head`, que funciona também para forks;
|
|
6
|
+
* o base vem de `origin/<baseRefName>`. Fail-open na leitura, mas a revisão só
|
|
7
|
+
* começa quando as refs necessárias existem (erro explícito caso contrário).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describeGhError, type GhRunner } from "./gh-client";
|
|
11
|
+
import type { GitRepository } from "./git-repository";
|
|
12
|
+
|
|
13
|
+
const PR_FIELDS =
|
|
14
|
+
"number,title,headRefName,baseRefName,headRefOid,isCrossRepository,url,isDraft";
|
|
15
|
+
|
|
16
|
+
/** Dados mínimos de uma PR para montar o worktree. */
|
|
17
|
+
export type PullRequestInfo = {
|
|
18
|
+
number: number;
|
|
19
|
+
title?: string;
|
|
20
|
+
headRefName: string;
|
|
21
|
+
baseRefName: string;
|
|
22
|
+
/** Commit exato do head no momento da consulta. */
|
|
23
|
+
headSha?: string;
|
|
24
|
+
isCrossRepository: boolean;
|
|
25
|
+
isDraft: boolean;
|
|
26
|
+
url?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Resultado da consulta ao `gh`. */
|
|
30
|
+
export type PrResolveResult =
|
|
31
|
+
| { ok: true; pr: PullRequestInfo }
|
|
32
|
+
| { ok: false; reason: "gh" | "invalid"; detail: string };
|
|
33
|
+
|
|
34
|
+
/** Refs locais preparadas para o diff. */
|
|
35
|
+
export type PrRefs = {
|
|
36
|
+
base: string;
|
|
37
|
+
head: string;
|
|
38
|
+
/** `true` quando o fetch trouxe algo novo. */
|
|
39
|
+
fetched: boolean;
|
|
40
|
+
/** Aviso (ex.: fetch parcial) para o CLI imprimir. */
|
|
41
|
+
warning?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type GhPrView = {
|
|
45
|
+
number?: unknown;
|
|
46
|
+
title?: unknown;
|
|
47
|
+
headRefName?: unknown;
|
|
48
|
+
baseRefName?: unknown;
|
|
49
|
+
headRefOid?: unknown;
|
|
50
|
+
isCrossRepository?: unknown;
|
|
51
|
+
isDraft?: unknown;
|
|
52
|
+
url?: unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Cliente de PR sobre um runner injetável. */
|
|
56
|
+
export class PrResolver {
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly runner: GhRunner,
|
|
59
|
+
private readonly git: GitRepository,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
/** Consulta a PR no GitHub (nunca lança). */
|
|
63
|
+
resolve(number: number): PrResolveResult {
|
|
64
|
+
const result = this.runner(["pr", "view", String(number), "--json", PR_FIELDS]);
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
return { ok: false, reason: "gh", detail: describeGhError(result) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let parsed: GhPrView;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(result.stdout) as GhPrView;
|
|
72
|
+
} catch {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "invalid",
|
|
76
|
+
detail: "Resposta inválida do gh (JSON malformado).",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const headRefName =
|
|
81
|
+
typeof parsed.headRefName === "string" ? parsed.headRefName.trim() : "";
|
|
82
|
+
const baseRefName =
|
|
83
|
+
typeof parsed.baseRefName === "string" ? parsed.baseRefName.trim() : "";
|
|
84
|
+
const resolvedNumber = Number(parsed.number);
|
|
85
|
+
if (
|
|
86
|
+
!Number.isFinite(resolvedNumber) ||
|
|
87
|
+
resolvedNumber <= 0 ||
|
|
88
|
+
headRefName.length === 0 ||
|
|
89
|
+
baseRefName.length === 0
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: "invalid",
|
|
94
|
+
detail: "gh não devolveu número/head/base da PR.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
pr: {
|
|
101
|
+
number: resolvedNumber,
|
|
102
|
+
title: typeof parsed.title === "string" ? parsed.title : undefined,
|
|
103
|
+
headRefName,
|
|
104
|
+
baseRefName,
|
|
105
|
+
headSha:
|
|
106
|
+
typeof parsed.headRefOid === "string"
|
|
107
|
+
? parsed.headRefOid.trim()
|
|
108
|
+
: undefined,
|
|
109
|
+
isCrossRepository: Boolean(parsed.isCrossRepository),
|
|
110
|
+
isDraft: Boolean(parsed.isDraft),
|
|
111
|
+
url: typeof parsed.url === "string" ? parsed.url : undefined,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Garante base/head da PR no repositório local.
|
|
118
|
+
*
|
|
119
|
+
* Prefere o SHA informado pelo `gh`; cai para `refs/houndsman/pr-<n>` quando
|
|
120
|
+
* o objeto não existe localmente.
|
|
121
|
+
*/
|
|
122
|
+
prepareRefs(pr: PullRequestInfo): PrRefs {
|
|
123
|
+
const headRef = `refs/houndsman/pr-${pr.number}`;
|
|
124
|
+
const headFetch = this.git.fetch("origin", [
|
|
125
|
+
`+refs/pull/${pr.number}/head:${headRef}`,
|
|
126
|
+
]);
|
|
127
|
+
const baseFetch = this.git.fetch("origin", [pr.baseRefName]);
|
|
128
|
+
|
|
129
|
+
const originBase = `origin/${pr.baseRefName}`;
|
|
130
|
+
const hasOriginBase = this.git.tryRun(["rev-parse", "--verify", originBase]).ok;
|
|
131
|
+
const base = hasOriginBase ? originBase : pr.baseRefName;
|
|
132
|
+
|
|
133
|
+
const hasSha = pr.headSha
|
|
134
|
+
? this.git.tryRun(["cat-file", "-e", `${pr.headSha}^{commit}`]).ok
|
|
135
|
+
: false;
|
|
136
|
+
const head = hasSha && pr.headSha ? pr.headSha : headRef;
|
|
137
|
+
|
|
138
|
+
const warning =
|
|
139
|
+
!headFetch.ok && !hasSha
|
|
140
|
+
? `Não foi possível buscar o head da PR: ${headFetch.stderr.trim()}`
|
|
141
|
+
: !baseFetch.ok
|
|
142
|
+
? `Não foi possível atualizar ${pr.baseRefName}: ${baseFetch.stderr.trim()}`
|
|
143
|
+
: undefined;
|
|
144
|
+
|
|
145
|
+
return { base, head, fetched: headFetch.ok || baseFetch.ok, warning };
|
|
146
|
+
}
|
|
147
|
+
}
|