@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.
Files changed (71) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/server-command.ts +7 -1
  8. package/src/cli/commands/worktree-command.ts +118 -0
  9. package/src/cli/help.ts +13 -3
  10. package/src/cli/index.ts +14 -4
  11. package/src/core/ast/service.ts +7 -2
  12. package/src/core/brief.ts +1 -0
  13. package/src/core/commands/context.ts +9 -1
  14. package/src/core/commands/findings.ts +17 -6
  15. package/src/core/commands/review.ts +13 -0
  16. package/src/core/commands/scaffold.ts +4 -2
  17. package/src/core/config.ts +13 -0
  18. package/src/core/defaults.ts +8 -1
  19. package/src/core/engine/houndsman-engine.ts +26 -3
  20. package/src/core/engine/review-scope.ts +17 -22
  21. package/src/core/engine/review-session.ts +25 -2
  22. package/src/core/engine/review-workspace.ts +204 -0
  23. package/src/core/git-runner.ts +25 -13
  24. package/src/core/github-review.ts +10 -7
  25. package/src/core/jev/jev-detector.ts +10 -2
  26. package/src/core/orchestrator.ts +62 -13
  27. package/src/core/project-config.ts +12 -0
  28. package/src/core/services/gh-client.ts +38 -22
  29. package/src/core/services/git-repository.ts +46 -0
  30. package/src/core/services/pr-resolver.ts +147 -0
  31. package/src/core/services/worktree-manager.ts +269 -0
  32. package/src/core/storage/file/file-storage.ts +14 -1
  33. package/src/core/storage/paths.ts +30 -1
  34. package/src/core/storage/sqlite/mappers.ts +4 -0
  35. package/src/core/storage/sqlite/schema.ts +13 -3
  36. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  37. package/src/core/storage/types.ts +12 -0
  38. package/src/core/storage/work-area.ts +20 -1
  39. package/src/core/types.ts +2 -0
  40. package/src/harness/hunt/hunt-options.ts +17 -0
  41. package/src/harness/hunt/hunt-session.ts +7 -20
  42. package/src/index.ts +31 -0
  43. package/src/server/client/App.tsx +86 -22
  44. package/src/server/client/CommentsPanel.tsx +255 -0
  45. package/src/server/client/DeleteRunModal.tsx +163 -0
  46. package/src/server/client/FindingCard.tsx +12 -4
  47. package/src/server/client/FindingsList.tsx +4 -1
  48. package/src/server/client/ResumePanel.tsx +8 -8
  49. package/src/server/client/Sidebar.tsx +452 -0
  50. package/src/server/client/SubmitModal.tsx +61 -5
  51. package/src/server/client/icons.tsx +29 -0
  52. package/src/server/client/index.tsx +1 -1
  53. package/src/server/client/utils/constants.ts +2 -0
  54. package/src/server/client/utils/format.ts +47 -2
  55. package/src/server/client/utils/types.ts +108 -0
  56. package/src/server/editor.ts +129 -0
  57. package/src/server/index.ts +205 -3
  58. package/src/server/pr-comments.ts +256 -0
  59. package/src/server/pr-inbox.ts +213 -0
  60. package/src/server/run-repository.ts +136 -3
  61. package/src/skill/README.md +11 -3
  62. package/src/skill/SKILL.md +44 -10
  63. package/src/skill/hunt.md +33 -7
  64. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  65. package/src/tests/ai-core.test.ts +2 -2
  66. package/src/tests/cli.test.ts +35 -1
  67. package/src/tests/github-review.test.ts +9 -0
  68. package/src/tests/server-editor.test.ts +100 -0
  69. package/src/tests/server.test.ts +333 -2
  70. package/src/tests/worktree.test.ts +369 -0
  71. package/src/server/client/RunList.tsx +0 -75
@@ -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
- /** Contexto de uma run: storage + review + staging. */
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
- return { storage, review, work: new ReviewWorkArea(review) };
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: OUTPUT_FORMAT_PATH,
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
- return readFileSync(file, "utf8").split("\n").slice(0, 80).join("\n");
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(process.cwd()),
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(opts: ReviewOptions): string {
1046
- return opts.branch ?? currentBranch();
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
- /** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
14
- export const defaultGhRunner: GhRunner = (args) => {
15
- try {
16
- const result = Bun.spawnSync(["gh", ...args], {
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
+ }