@ronkovic/aad 0.3.9 → 0.5.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 (132) hide show
  1. package/README.md +332 -14
  2. package/package.json +6 -1
  3. package/src/__tests__/e2e/cleanup-e2e.test.ts +186 -0
  4. package/src/__tests__/e2e/dashboard-api-e2e.test.ts +87 -0
  5. package/src/__tests__/e2e/pipeline-e2e.test.ts +10 -68
  6. package/src/__tests__/e2e/resume-e2e.test.ts +9 -11
  7. package/src/__tests__/e2e/retry-e2e.test.ts +285 -0
  8. package/src/__tests__/e2e/status-e2e.test.ts +227 -0
  9. package/src/__tests__/e2e/tdd-pipeline-e2e.test.ts +360 -0
  10. package/src/__tests__/helpers/index.ts +6 -0
  11. package/src/__tests__/helpers/mock-claude-provider.ts +53 -0
  12. package/src/__tests__/helpers/mock-logger.ts +36 -0
  13. package/src/__tests__/helpers/wait-helpers.ts +34 -0
  14. package/src/__tests__/integration/pipeline.test.ts +3 -0
  15. package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +1 -1
  16. package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +6 -0
  17. package/src/modules/claude-provider/__tests__/provider-registry.test.ts +3 -0
  18. package/src/modules/cli/__tests__/cleanup.test.ts +73 -0
  19. package/src/modules/cli/__tests__/resume.test.ts +4 -0
  20. package/src/modules/cli/__tests__/run.test.ts +37 -0
  21. package/src/modules/cli/__tests__/status.test.ts +1 -0
  22. package/src/modules/cli/app.ts +2 -0
  23. package/src/modules/cli/commands/__tests__/task-dispatch-handler.test.ts +145 -0
  24. package/src/modules/cli/commands/cleanup.ts +26 -11
  25. package/src/modules/cli/commands/resume.ts +14 -8
  26. package/src/modules/cli/commands/run.ts +70 -8
  27. package/src/modules/cli/commands/task-dispatch-handler.ts +73 -3
  28. package/src/modules/dashboard/__tests__/api-graph.test.ts +332 -0
  29. package/src/modules/dashboard/__tests__/api-timeline.test.ts +461 -0
  30. package/src/modules/dashboard/routes/sse.ts +3 -2
  31. package/src/modules/dashboard/server.ts +1 -0
  32. package/src/modules/dashboard/services/sse-broadcaster.ts +29 -0
  33. package/src/modules/dashboard/ui/dashboard.html +640 -349
  34. package/src/modules/git-workspace/__tests__/branch-manager.test.ts +52 -0
  35. package/src/modules/git-workspace/__tests__/dependency-installer.test.ts +77 -0
  36. package/src/modules/git-workspace/__tests__/git-exec.test.ts +26 -0
  37. package/src/modules/git-workspace/__tests__/merge-service.test.ts +19 -0
  38. package/src/modules/git-workspace/__tests__/pr-manager.test.ts +80 -0
  39. package/src/modules/git-workspace/__tests__/template-copy.test.ts +189 -0
  40. package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +29 -2
  41. package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +64 -4
  42. package/src/modules/git-workspace/branch-manager.ts +24 -3
  43. package/src/modules/git-workspace/dependency-installer.ts +113 -0
  44. package/src/modules/git-workspace/git-exec.ts +3 -2
  45. package/src/modules/git-workspace/index.ts +10 -1
  46. package/src/modules/git-workspace/merge-service.ts +36 -2
  47. package/src/modules/git-workspace/pr-manager.ts +278 -0
  48. package/src/modules/git-workspace/template-copy.ts +302 -0
  49. package/src/modules/git-workspace/worktree-manager.ts +37 -11
  50. package/src/modules/planning/__tests__/planning-service.test.ts +3 -0
  51. package/src/modules/planning/__tests__/planning.service.test.ts +149 -0
  52. package/src/modules/planning/__tests__/project-detection.test.ts +7 -1
  53. package/src/modules/planning/planning.service.ts +16 -2
  54. package/src/modules/planning/project-detection.ts +4 -1
  55. package/src/modules/process-manager/__tests__/process-manager.test.ts +3 -0
  56. package/src/modules/process-manager/process-manager.ts +2 -1
  57. package/src/modules/task-execution/__tests__/executor.test.ts +496 -0
  58. package/src/modules/task-execution/__tests__/tester-verify.test.ts +4 -3
  59. package/src/modules/task-execution/executor.ts +163 -4
  60. package/src/modules/task-execution/phases/implementer-green.ts +22 -5
  61. package/src/modules/task-execution/phases/merge.ts +44 -2
  62. package/src/modules/task-execution/phases/tester-red.ts +22 -5
  63. package/src/modules/task-execution/phases/tester-verify.ts +22 -6
  64. package/src/modules/task-queue/dispatcher.ts +96 -3
  65. package/src/shared/__tests__/config.test.ts +30 -0
  66. package/src/shared/__tests__/events.test.ts +42 -16
  67. package/src/shared/__tests__/prerequisites.test.ts +176 -0
  68. package/src/shared/__tests__/shutdown-handler.test.ts +96 -0
  69. package/src/shared/config.ts +10 -0
  70. package/src/shared/events.ts +5 -0
  71. package/src/shared/memory-check.ts +2 -2
  72. package/src/shared/prerequisites.ts +190 -0
  73. package/src/shared/shutdown-handler.ts +12 -5
  74. package/src/shared/types.ts +25 -0
  75. package/templates/CLAUDE.md +122 -0
  76. package/templates/settings.json +117 -0
  77. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +0 -10
  78. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +0 -10
  79. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +0 -13
  80. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +0 -10
  81. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +0 -10
  82. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +0 -5
  83. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +0 -5
  84. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +0 -10
  85. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +0 -10
  86. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +0 -13
  87. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +0 -10
  88. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +0 -10
  89. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +0 -5
  90. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +0 -5
  91. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +0 -10
  92. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +0 -10
  93. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +0 -13
  94. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +0 -10
  95. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +0 -10
  96. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +0 -5
  97. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +0 -5
  98. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +0 -10
  99. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +0 -10
  100. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +0 -13
  101. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +0 -10
  102. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +0 -10
  103. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +0 -5
  104. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +0 -5
  105. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +0 -10
  106. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +0 -10
  107. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +0 -13
  108. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +0 -10
  109. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +0 -10
  110. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +0 -5
  111. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +0 -5
  112. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +0 -10
  113. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +0 -10
  114. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +0 -13
  115. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +0 -10
  116. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +0 -10
  117. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +0 -5
  118. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +0 -5
  119. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +0 -10
  120. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +0 -10
  121. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +0 -13
  122. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +0 -10
  123. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +0 -10
  124. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +0 -5
  125. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +0 -5
  126. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +0 -10
  127. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +0 -10
  128. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +0 -13
  129. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +0 -10
  130. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +0 -10
  131. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +0 -5
  132. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +0 -5
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Dependency Installer
3
+ * Installs package dependencies in task worktrees after creation.
4
+ */
5
+
6
+ import type { WorkspaceInfo } from "../../shared/types";
7
+ import type { Logger } from "pino";
8
+
9
+ export interface InstallResult {
10
+ success: boolean;
11
+ output: string;
12
+ duration: number;
13
+ skipped: boolean; // Go/Rust等で明示的にスキップした場合
14
+ }
15
+
16
+ /**
17
+ * パッケージマネージャからインストールコマンドを導出
18
+ * Returns null if no install command is available (unknown or no-op).
19
+ */
20
+ export function buildInstallCommand(workspace: WorkspaceInfo): string[] | null {
21
+ switch (workspace.packageManager) {
22
+ // Node.js 系
23
+ case "bun":
24
+ return ["bun", "install", "--frozen-lockfile"];
25
+ case "npm":
26
+ return ["npm", "ci"];
27
+ case "yarn":
28
+ return ["yarn", "install", "--frozen-lockfile"];
29
+ case "pnpm":
30
+ return ["pnpm", "install", "--frozen-lockfile"];
31
+
32
+ // Python 系
33
+ case "uv":
34
+ return ["uv", "sync"];
35
+ case "poetry":
36
+ return ["poetry", "install", "--no-interaction"];
37
+ case "pipenv":
38
+ return ["pipenv", "install"];
39
+ case "pip":
40
+ return ["pip", "install", "-r", "requirements.txt"];
41
+
42
+ // Go / Rust (テスト実行時に自動ダウンロードするが、先にfetchしておく)
43
+ case "go":
44
+ return ["go", "mod", "download"];
45
+ case "cargo":
46
+ return ["cargo", "fetch"];
47
+
48
+ case "unknown":
49
+ default:
50
+ return null; // インストール不要またはスキップ
51
+ }
52
+ }
53
+
54
+ /**
55
+ * worktreeの依存パッケージをインストール
56
+ * Non-fatal: 失敗してもエラーをthrowせず、InstallResultで報告。
57
+ * Claude CLIエージェントは自分でinstallコマンドを実行できるため、ここでの失敗は致命的ではない。
58
+ */
59
+ export async function installDependencies(
60
+ workspace: WorkspaceInfo,
61
+ logger: Logger,
62
+ timeout?: number
63
+ ): Promise<InstallResult> {
64
+ const startTime = Date.now();
65
+ const command = buildInstallCommand(workspace);
66
+
67
+ if (!command) {
68
+ logger.debug(
69
+ { packageManager: workspace.packageManager },
70
+ "No install command for package manager, skipping"
71
+ );
72
+ return { success: true, output: "", duration: 0, skipped: true };
73
+ }
74
+
75
+ const [cmd, ...args] = command;
76
+ logger.info({ cmd, args, cwd: workspace.path }, "Installing dependencies");
77
+
78
+ // Bun.spawn で実行(default-spawner.ts と同パターン)
79
+ const proc = Bun.spawn([cmd!, ...args], {
80
+ cwd: workspace.path,
81
+ stdout: "pipe",
82
+ stderr: "pipe",
83
+ });
84
+
85
+ const installTimeout = timeout ?? 180000; // デフォルト3分
86
+ const timer = setTimeout(() => proc.kill(), installTimeout);
87
+
88
+ try {
89
+ const [stdout, stderr, exitCode] = await Promise.all([
90
+ new Response(proc.stdout).text(),
91
+ new Response(proc.stderr).text(),
92
+ proc.exited,
93
+ ]);
94
+ clearTimeout(timer);
95
+ const duration = Date.now() - startTime;
96
+
97
+ if (exitCode !== 0) {
98
+ logger.warn(
99
+ { exitCode, stderr: stderr.slice(0, 500) },
100
+ "Dependency install failed"
101
+ );
102
+ return { success: false, output: stderr || stdout, duration, skipped: false };
103
+ }
104
+
105
+ logger.info({ duration }, "Dependencies installed successfully");
106
+ return { success: true, output: stdout, duration, skipped: false };
107
+ } catch (error) {
108
+ clearTimeout(timer);
109
+ const duration = Date.now() - startTime;
110
+ logger.warn({ error }, "Dependency install error");
111
+ return { success: false, output: String(error), duration, skipped: false };
112
+ }
113
+ }
@@ -5,6 +5,7 @@ export interface GitExecOptions {
5
5
  cwd?: string;
6
6
  timeout?: number;
7
7
  logger?: Logger;
8
+ allowNonZeroExit?: boolean;
8
9
  }
9
10
 
10
11
  export interface GitExecResult {
@@ -21,7 +22,7 @@ export async function gitExec(
21
22
  args: string[],
22
23
  options: GitExecOptions = {}
23
24
  ): Promise<GitExecResult> {
24
- const { cwd = process.cwd(), timeout = 30000, logger } = options;
25
+ const { cwd = process.cwd(), timeout = 30000, logger, allowNonZeroExit = false } = options;
25
26
 
26
27
  logger?.debug({ args, cwd }, "Executing git command");
27
28
 
@@ -45,7 +46,7 @@ export async function gitExec(
45
46
 
46
47
  clearTimeout(timeoutId);
47
48
 
48
- if (exitCode !== 0) {
49
+ if (exitCode !== 0 && !allowNonZeroExit) {
49
50
  logger?.error({ args, exitCode, stderr }, "Git command failed");
50
51
  throw new GitWorkspaceError("Git command failed", {
51
52
  args,
@@ -5,7 +5,7 @@ export { WorktreeManager, cleanupOrphanedFromPreviousRuns } from "./worktree-man
5
5
  export type { WorktreeManagerOptions, WorktreeManagerFsOps } from "./worktree-manager";
6
6
 
7
7
  export { BranchManager } from "./branch-manager";
8
- export type { BranchManagerOptions } from "./branch-manager";
8
+ export type { BranchManagerOptions, CleanupBranchOptions } from "./branch-manager";
9
9
 
10
10
  export { MergeService } from "./merge-service";
11
11
  export type { MergeServiceOptions, MergeResult } from "./merge-service";
@@ -15,3 +15,12 @@ export type { ClaudeSettings } from "./settings-merge";
15
15
 
16
16
  export { harvestMemory } from "./memory-sync";
17
17
  export type { MemorySyncOptions, MemorySyncFsOps } from "./memory-sync";
18
+
19
+ export { copyTemplatesToWorktree, resolveTemplateDir } from "./template-copy";
20
+ export type { CopyTemplatesOptions } from "./template-copy";
21
+
22
+ export { PrManager } from "./pr-manager";
23
+ export type { PrManagerOptions } from "./pr-manager";
24
+
25
+ export { installDependencies, buildInstallCommand } from "./dependency-installer";
26
+ export type { InstallResult } from "./dependency-installer";
@@ -14,6 +14,7 @@ export interface MergeResult {
14
14
  success: boolean;
15
15
  conflicts?: string[];
16
16
  message?: string;
17
+ alreadyUpToDate?: boolean;
17
18
  }
18
19
 
19
20
  /**
@@ -68,21 +69,54 @@ export class MergeService {
68
69
  try {
69
70
  await lock.acquire();
70
71
 
72
+ // Check for stale MERGE_HEAD (interrupted previous merge) and abort
73
+ const mergeHeadPath = join(gitDir, "MERGE_HEAD");
74
+ try {
75
+ const mergeHeadExists = await Bun.file(mergeHeadPath).exists();
76
+ if (mergeHeadExists) {
77
+ this.logger?.warn({ taskId, parentWorktree }, "Stale MERGE_HEAD detected, aborting previous merge");
78
+ await gitExec(["merge", "--abort"], { cwd: parentWorktree, logger: this.logger });
79
+ }
80
+ } catch (_cleanupError) {
81
+ // Ignore cleanup errors
82
+ }
83
+
71
84
  this.logger?.info({ taskId, taskBranch, parentBranch, parentWorktree }, "Merging task to parent");
72
85
 
86
+ // Before merge: commit any untracked files in parent worktree
87
+ // (shell parity: worker-executor.sh L388-393)
88
+ const status = await gitExec(["status", "--porcelain"], {
89
+ cwd: parentWorktree,
90
+ logger: this.logger,
91
+ });
92
+ if (status.stdout.trim() !== "") {
93
+ this.logger?.info({ parentWorktree }, "Committing untracked files before merge");
94
+ await gitExec(["add", "-A"], { cwd: parentWorktree, logger: this.logger });
95
+ await gitExec(
96
+ ["commit", "--no-gpg-sign", "-m", "chore: add template files before merge"],
97
+ { cwd: parentWorktree, logger: this.logger }
98
+ );
99
+ }
100
+
73
101
  // Worktrees share the same git object store, so we can merge the task
74
102
  // branch directly without fetching. This is more reliable than
75
103
  // `git fetch <repoRoot> <branch>` + `git merge FETCH_HEAD`.
76
104
  try {
77
- await gitExec(
105
+ const mergeOutput = await gitExec(
78
106
  ["merge", "--no-ff", "-m", `Merge task ${taskId as string}: ${taskBranch}`, taskBranch],
79
107
  { cwd: parentWorktree, logger: this.logger }
80
108
  );
81
109
 
82
- this.logger?.info({ taskId, taskBranch }, "Merge succeeded");
110
+ const alreadyUpToDate = mergeOutput.stdout.includes("Already up to date");
111
+ if (alreadyUpToDate) {
112
+ this.logger?.warn({ taskId, taskBranch }, "No new commits on task branch");
113
+ } else {
114
+ this.logger?.info({ taskId, taskBranch }, "Merge succeeded");
115
+ }
83
116
 
84
117
  return {
85
118
  success: true,
119
+ alreadyUpToDate,
86
120
  message: `Successfully merged ${taskBranch} into ${parentBranch}`,
87
121
  };
88
122
  } catch (error) {
@@ -0,0 +1,278 @@
1
+ import type { Logger } from "pino";
2
+ import { GitWorkspaceError } from "@aad/shared/errors";
3
+
4
+ export interface PrManagerOptions {
5
+ repoRoot: string;
6
+ logger?: Logger;
7
+ }
8
+
9
+ export interface DraftPrOptions {
10
+ title: string;
11
+ body: string;
12
+ baseBranch: string;
13
+ headBranch: string;
14
+ }
15
+
16
+ export interface PrInfo {
17
+ number: number;
18
+ url: string;
19
+ state: "draft" | "open" | "closed";
20
+ }
21
+
22
+ /**
23
+ * Manage Pull Request lifecycle via GitHub CLI (gh)
24
+ */
25
+ export class PrManager {
26
+ private repoRoot: string;
27
+ private logger?: Logger;
28
+
29
+ constructor(options: PrManagerOptions) {
30
+ this.repoRoot = options.repoRoot;
31
+ this.logger = options.logger;
32
+ }
33
+
34
+ /**
35
+ * Check if gh CLI is installed
36
+ */
37
+ async checkGhInstalled(): Promise<boolean> {
38
+ try {
39
+ const proc = Bun.spawn(["gh", "--version"], {
40
+ cwd: this.repoRoot,
41
+ stdout: "pipe",
42
+ stderr: "pipe",
43
+ });
44
+ const exitCode = await proc.exited;
45
+ return exitCode === 0;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Create a draft PR
53
+ */
54
+ async createDraftPr(options: DraftPrOptions): Promise<PrInfo> {
55
+ this.logger?.info({ headBranch: options.headBranch, baseBranch: options.baseBranch }, "Creating draft PR");
56
+
57
+ if (!(await this.checkGhInstalled())) {
58
+ throw new GitWorkspaceError("GitHub CLI (gh) is not installed or not in PATH", {
59
+ suggestion: "Install gh from https://cli.github.com/",
60
+ });
61
+ }
62
+
63
+ try {
64
+ // Create draft PR using gh CLI
65
+ const proc = Bun.spawn(
66
+ [
67
+ "gh",
68
+ "pr",
69
+ "create",
70
+ "--draft",
71
+ "--title",
72
+ options.title,
73
+ "--body",
74
+ options.body,
75
+ "--base",
76
+ options.baseBranch,
77
+ "--head",
78
+ options.headBranch,
79
+ ],
80
+ {
81
+ cwd: this.repoRoot,
82
+ stdout: "pipe",
83
+ stderr: "pipe",
84
+ }
85
+ );
86
+
87
+ const exitCode = await proc.exited;
88
+ const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
89
+ const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
90
+
91
+ if (exitCode !== 0) {
92
+ throw new Error(`gh pr create failed: ${stderr}`);
93
+ }
94
+
95
+ // Parse PR URL from stdout (gh prints the PR URL)
96
+ const url = stdout.trim();
97
+ const match = url.match(/\/pull\/(\d+)$/);
98
+ const number = match?.[1] ? parseInt(match[1], 10) : 0;
99
+
100
+ this.logger?.info({ prNumber: number, url }, "Draft PR created");
101
+
102
+ return {
103
+ number,
104
+ url,
105
+ state: "draft",
106
+ };
107
+ } catch (error) {
108
+ throw new GitWorkspaceError("Failed to create draft PR", {
109
+ headBranch: options.headBranch,
110
+ baseBranch: options.baseBranch,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Mark draft PR as ready for review
118
+ */
119
+ async markPrReady(prNumber: number): Promise<void> {
120
+ this.logger?.info({ prNumber }, "Marking PR as ready for review");
121
+
122
+ if (!(await this.checkGhInstalled())) {
123
+ throw new GitWorkspaceError("GitHub CLI (gh) is not installed or not in PATH", {
124
+ suggestion: "Install gh from https://cli.github.com/",
125
+ });
126
+ }
127
+
128
+ try {
129
+ const proc = Bun.spawn(["gh", "pr", "ready", String(prNumber)], {
130
+ cwd: this.repoRoot,
131
+ stdout: "pipe",
132
+ stderr: "pipe",
133
+ });
134
+
135
+ const exitCode = await proc.exited;
136
+ const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
137
+
138
+ if (exitCode !== 0) {
139
+ throw new Error(`gh pr ready failed: ${stderr}`);
140
+ }
141
+
142
+ this.logger?.info({ prNumber }, "PR marked as ready");
143
+ } catch (error) {
144
+ throw new GitWorkspaceError("Failed to mark PR as ready", {
145
+ prNumber,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get PR info
153
+ */
154
+ async getPrInfo(prNumber: number): Promise<PrInfo | null> {
155
+ if (!(await this.checkGhInstalled())) {
156
+ return null;
157
+ }
158
+
159
+ try {
160
+ const proc = Bun.spawn(
161
+ ["gh", "pr", "view", String(prNumber), "--json", "number,url,isDraft,state"],
162
+ {
163
+ cwd: this.repoRoot,
164
+ stdout: "pipe",
165
+ stderr: "pipe",
166
+ }
167
+ );
168
+
169
+ const exitCode = await proc.exited;
170
+ const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
171
+
172
+ if (exitCode !== 0) {
173
+ return null;
174
+ }
175
+
176
+ const data = JSON.parse(stdout) as {
177
+ number: number;
178
+ url: string;
179
+ isDraft: boolean;
180
+ state: string;
181
+ };
182
+
183
+ return {
184
+ number: data.number,
185
+ url: data.url,
186
+ state: data.isDraft ? "draft" : data.state === "OPEN" ? "open" : "closed",
187
+ };
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Close PR
195
+ */
196
+ async closePr(prNumber: number): Promise<void> {
197
+ this.logger?.info({ prNumber }, "Closing PR");
198
+
199
+ if (!(await this.checkGhInstalled())) {
200
+ throw new GitWorkspaceError("GitHub CLI (gh) is not installed or not in PATH", {
201
+ suggestion: "Install gh from https://cli.github.com/",
202
+ });
203
+ }
204
+
205
+ try {
206
+ const proc = Bun.spawn(["gh", "pr", "close", String(prNumber)], {
207
+ cwd: this.repoRoot,
208
+ stdout: "pipe",
209
+ stderr: "pipe",
210
+ });
211
+
212
+ const exitCode = await proc.exited;
213
+ const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
214
+
215
+ if (exitCode !== 0) {
216
+ throw new Error(`gh pr close failed: ${stderr}`);
217
+ }
218
+
219
+ this.logger?.info({ prNumber }, "PR closed");
220
+ } catch (error) {
221
+ throw new GitWorkspaceError("Failed to close PR", {
222
+ prNumber,
223
+ error: error instanceof Error ? error.message : String(error),
224
+ });
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Find PR by head branch
230
+ */
231
+ async findPrByBranch(headBranch: string): Promise<PrInfo | null> {
232
+ if (!(await this.checkGhInstalled())) {
233
+ return null;
234
+ }
235
+
236
+ try {
237
+ const proc = Bun.spawn(
238
+ ["gh", "pr", "list", "--head", headBranch, "--json", "number,url,isDraft,state", "--limit", "1"],
239
+ {
240
+ cwd: this.repoRoot,
241
+ stdout: "pipe",
242
+ stderr: "pipe",
243
+ }
244
+ );
245
+
246
+ const exitCode = await proc.exited;
247
+ const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
248
+
249
+ if (exitCode !== 0) {
250
+ return null;
251
+ }
252
+
253
+ const data = JSON.parse(stdout) as Array<{
254
+ number: number;
255
+ url: string;
256
+ isDraft: boolean;
257
+ state: string;
258
+ }>;
259
+
260
+ if (data.length === 0) {
261
+ return null;
262
+ }
263
+
264
+ const pr = data[0];
265
+ if (!pr) {
266
+ return null;
267
+ }
268
+
269
+ return {
270
+ number: pr.number,
271
+ url: pr.url,
272
+ state: pr.isDraft ? "draft" : pr.state === "OPEN" ? "open" : "closed",
273
+ };
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+ }