@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.
- package/README.md +332 -14
- package/package.json +6 -1
- package/src/__tests__/e2e/cleanup-e2e.test.ts +186 -0
- package/src/__tests__/e2e/dashboard-api-e2e.test.ts +87 -0
- package/src/__tests__/e2e/pipeline-e2e.test.ts +10 -68
- package/src/__tests__/e2e/resume-e2e.test.ts +9 -11
- package/src/__tests__/e2e/retry-e2e.test.ts +285 -0
- package/src/__tests__/e2e/status-e2e.test.ts +227 -0
- package/src/__tests__/e2e/tdd-pipeline-e2e.test.ts +360 -0
- package/src/__tests__/helpers/index.ts +6 -0
- package/src/__tests__/helpers/mock-claude-provider.ts +53 -0
- package/src/__tests__/helpers/mock-logger.ts +36 -0
- package/src/__tests__/helpers/wait-helpers.ts +34 -0
- package/src/__tests__/integration/pipeline.test.ts +3 -0
- package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +1 -1
- package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +6 -0
- package/src/modules/claude-provider/__tests__/provider-registry.test.ts +3 -0
- package/src/modules/cli/__tests__/cleanup.test.ts +73 -0
- package/src/modules/cli/__tests__/resume.test.ts +4 -0
- package/src/modules/cli/__tests__/run.test.ts +37 -0
- package/src/modules/cli/__tests__/status.test.ts +1 -0
- package/src/modules/cli/app.ts +2 -0
- package/src/modules/cli/commands/__tests__/task-dispatch-handler.test.ts +145 -0
- package/src/modules/cli/commands/cleanup.ts +26 -11
- package/src/modules/cli/commands/resume.ts +14 -8
- package/src/modules/cli/commands/run.ts +70 -8
- package/src/modules/cli/commands/task-dispatch-handler.ts +73 -3
- package/src/modules/dashboard/__tests__/api-graph.test.ts +332 -0
- package/src/modules/dashboard/__tests__/api-timeline.test.ts +461 -0
- package/src/modules/dashboard/routes/sse.ts +3 -2
- package/src/modules/dashboard/server.ts +1 -0
- package/src/modules/dashboard/services/sse-broadcaster.ts +29 -0
- package/src/modules/dashboard/ui/dashboard.html +640 -349
- package/src/modules/git-workspace/__tests__/branch-manager.test.ts +52 -0
- package/src/modules/git-workspace/__tests__/dependency-installer.test.ts +77 -0
- package/src/modules/git-workspace/__tests__/git-exec.test.ts +26 -0
- package/src/modules/git-workspace/__tests__/merge-service.test.ts +19 -0
- package/src/modules/git-workspace/__tests__/pr-manager.test.ts +80 -0
- package/src/modules/git-workspace/__tests__/template-copy.test.ts +189 -0
- package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +29 -2
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +64 -4
- package/src/modules/git-workspace/branch-manager.ts +24 -3
- package/src/modules/git-workspace/dependency-installer.ts +113 -0
- package/src/modules/git-workspace/git-exec.ts +3 -2
- package/src/modules/git-workspace/index.ts +10 -1
- package/src/modules/git-workspace/merge-service.ts +36 -2
- package/src/modules/git-workspace/pr-manager.ts +278 -0
- package/src/modules/git-workspace/template-copy.ts +302 -0
- package/src/modules/git-workspace/worktree-manager.ts +37 -11
- package/src/modules/planning/__tests__/planning-service.test.ts +3 -0
- package/src/modules/planning/__tests__/planning.service.test.ts +149 -0
- package/src/modules/planning/__tests__/project-detection.test.ts +7 -1
- package/src/modules/planning/planning.service.ts +16 -2
- package/src/modules/planning/project-detection.ts +4 -1
- package/src/modules/process-manager/__tests__/process-manager.test.ts +3 -0
- package/src/modules/process-manager/process-manager.ts +2 -1
- package/src/modules/task-execution/__tests__/executor.test.ts +496 -0
- package/src/modules/task-execution/__tests__/tester-verify.test.ts +4 -3
- package/src/modules/task-execution/executor.ts +163 -4
- package/src/modules/task-execution/phases/implementer-green.ts +22 -5
- package/src/modules/task-execution/phases/merge.ts +44 -2
- package/src/modules/task-execution/phases/tester-red.ts +22 -5
- package/src/modules/task-execution/phases/tester-verify.ts +22 -6
- package/src/modules/task-queue/dispatcher.ts +96 -3
- package/src/shared/__tests__/config.test.ts +30 -0
- package/src/shared/__tests__/events.test.ts +42 -16
- package/src/shared/__tests__/prerequisites.test.ts +176 -0
- package/src/shared/__tests__/shutdown-handler.test.ts +96 -0
- package/src/shared/config.ts +10 -0
- package/src/shared/events.ts +5 -0
- package/src/shared/memory-check.ts +2 -2
- package/src/shared/prerequisites.ts +190 -0
- package/src/shared/shutdown-handler.ts +12 -5
- package/src/shared/types.ts +25 -0
- package/templates/CLAUDE.md +122 -0
- package/templates/settings.json +117 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +0 -5
- 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
|
-
|
|
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
|
+
}
|