ai-foreman 1.0.9 → 1.0.11
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 +21 -2
- package/dist/adapters/claude.js +14 -4
- package/dist/adapters/codex.js +5 -0
- package/dist/branch/git.d.ts +22 -0
- package/dist/branch/git.js +94 -0
- package/dist/branch/github.d.ts +21 -0
- package/dist/branch/github.js +414 -0
- package/dist/branch/planner.d.ts +19 -0
- package/dist/branch/planner.js +196 -0
- package/dist/branch/resume.d.ts +21 -0
- package/dist/branch/resume.js +115 -0
- package/dist/branch/runner.d.ts +28 -0
- package/dist/branch/runner.js +365 -0
- package/dist/branch/types.d.ts +70 -0
- package/dist/branch/types.js +1 -0
- package/dist/cli/doctor.js +27 -1
- package/dist/cli/runtimeAuthPrompt.d.ts +2 -0
- package/dist/cli/runtimeAuthPrompt.js +27 -0
- package/dist/cli/start.js +275 -18
- package/dist/cli/status.js +148 -2
- package/dist/cli/tickets.js +2 -0
- package/dist/foreman.d.ts +6 -0
- package/dist/foreman.js +4 -0
- package/dist/log.d.ts +1 -1
- package/dist/runtimeAuth.d.ts +23 -0
- package/dist/runtimeAuth.js +116 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,6 +94,14 @@ What each part does:
|
|
|
94
94
|
| `--no-qa` | flag | QA on | Disables per-ticket QA review. |
|
|
95
95
|
| `--continue` | flag | off | Resumes the most recent logged session. |
|
|
96
96
|
| `-r, --resume <sessionId>` | session ID from `.foreman/` logs | none | Resumes a specific Claude/Codex session. |
|
|
97
|
+
| `--branch-per-ticket` | flag | off | Runs each selected structured ticket in its own git worktree and branch. Requires initialized `.tickets/`. |
|
|
98
|
+
| `--create-pr` | flag | off | Pushes each successful ticket branch and creates a GitHub PR. Implies `--branch-per-ticket`. |
|
|
99
|
+
| `--pr-ready` | flag | draft PRs | Creates ready-for-review PRs instead of draft PRs when used with `--create-pr`. |
|
|
100
|
+
| `--keep-worktrees` | flag | remove successful worktrees | Keeps successful ticket worktrees for inspection. Blocked worktrees are kept automatically. |
|
|
101
|
+
| `--ticket <id>` | `T001` | none | Selects a branch-mode ticket to continue. Repeatable with `--continue`. |
|
|
102
|
+
| `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
|
|
103
|
+
| `--branch-prefix <prefix>` | `rafi` / `feature` | `rafi` | Prefix for generated ticket branches. |
|
|
104
|
+
| `--max-branch-depth <n>` | `1` / `2` / `3` | `2` | Maximum selected branch stack depth. |
|
|
97
105
|
|
|
98
106
|
Auto-detected tracker files:
|
|
99
107
|
|
|
@@ -104,6 +112,17 @@ Resume rule:
|
|
|
104
112
|
|
|
105
113
|
- Use either `--continue` or `--resume`.
|
|
106
114
|
- Do not use both in the same command.
|
|
115
|
+
- In branch mode, use `--continue --ticket <id>` to continue a ticket with its saved builder session.
|
|
116
|
+
- For multiple branch tickets, repeat `--ticket` with `--continue`; do not reuse one explicit `--resume <sessionId>` across multiple tickets.
|
|
117
|
+
- When a branch run ends blocked, Foreman prints paste-ready continue commands for resumable tickets.
|
|
118
|
+
|
|
119
|
+
Branch-mode examples:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
ai-foreman start ./my-project --steps 3 --branch-per-ticket --agent codex
|
|
123
|
+
ai-foreman start ./my-project --steps 3 --create-pr --pr-ready --agent codex
|
|
124
|
+
ai-foreman start ./my-project --steps 1 --branch-per-ticket --continue --ticket T001
|
|
125
|
+
```
|
|
107
126
|
|
|
108
127
|
### `ai-foreman tickets populate`
|
|
109
128
|
|
|
@@ -145,12 +164,14 @@ Claude Code:
|
|
|
145
164
|
- Default agent.
|
|
146
165
|
- Uses your existing Claude Code credentials.
|
|
147
166
|
- Runs through the Claude Agent SDK.
|
|
167
|
+
- `start` and `tickets populate` check Claude auth before launching the builder and prompt you to repair it if needed.
|
|
148
168
|
|
|
149
169
|
Codex:
|
|
150
170
|
|
|
151
171
|
- Use `--agent codex`.
|
|
152
172
|
- Shells out to `codex exec`.
|
|
153
173
|
- Requires the `codex` CLI on your `PATH`.
|
|
174
|
+
- `start` and `tickets populate --agent codex` check Codex auth before launching the builder and prompt you to repair it if needed.
|
|
154
175
|
|
|
155
176
|
Task file:
|
|
156
177
|
|
|
@@ -521,5 +542,3 @@ Package output:
|
|
|
521
542
|
- **`ai-foreman`** — this runtime
|
|
522
543
|
- **`@rafi-ai/cli`** — CLI for `rafi create` and `rafi compile`
|
|
523
544
|
|
|
524
|
-
|
|
525
|
-
|
package/dist/adapters/claude.js
CHANGED
|
@@ -11,6 +11,7 @@ export async function requireClaudeSDK() {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
import { AsyncQueue } from "../util/asyncQueue.js";
|
|
14
|
+
import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
|
|
14
15
|
/**
|
|
15
16
|
* Pure function: build the `options` object passed to `query()`.
|
|
16
17
|
* Extracted so tests can assert on the shape without making a live SDK call.
|
|
@@ -50,8 +51,14 @@ export class ClaudeAdapter {
|
|
|
50
51
|
pending;
|
|
51
52
|
closed = false;
|
|
52
53
|
static async create(opts) {
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
try {
|
|
55
|
+
const { query } = await requireClaudeSDK();
|
|
56
|
+
return new ClaudeAdapter(opts, query);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
throw new Error(normalizeRuntimeErrorText("claude", message, null, "adapter startup"), { cause: err });
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
64
|
constructor(opts, query) {
|
|
@@ -83,7 +90,8 @@ export class ClaudeAdapter {
|
|
|
83
90
|
(err instanceof Error &&
|
|
84
91
|
(err.name === "AbortError" || err.message.includes("aborted")));
|
|
85
92
|
if (!isShutdownAbort) {
|
|
86
|
-
const
|
|
93
|
+
const rawMessage = err instanceof Error ? err.message : String(err);
|
|
94
|
+
const message = normalizeRuntimeErrorText("claude", rawMessage, null, "builder stream");
|
|
87
95
|
this.eventQueue.push({ kind: "error", message });
|
|
88
96
|
this.pending?.reject(new Error(message));
|
|
89
97
|
this.pending = undefined;
|
|
@@ -118,7 +126,9 @@ export class ClaudeAdapter {
|
|
|
118
126
|
? msg.errors.join("; ")
|
|
119
127
|
: "";
|
|
120
128
|
const result = {
|
|
121
|
-
text
|
|
129
|
+
text: msg.is_error
|
|
130
|
+
? normalizeRuntimeErrorText("claude", text, null, "builder turn")
|
|
131
|
+
: text,
|
|
122
132
|
isError: msg.is_error,
|
|
123
133
|
numTurns: msg.num_turns,
|
|
124
134
|
costUsd: msg.total_cost_usd,
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { AsyncQueue } from "../util/asyncQueue.js";
|
|
4
|
+
import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
|
|
4
5
|
/**
|
|
5
6
|
* Pure function: parse one JSONL event from `codex exec --json` output into
|
|
6
7
|
* structured actions. Exported for unit testing; production code calls this
|
|
@@ -54,6 +55,7 @@ export class CodexAdapter {
|
|
|
54
55
|
_activeProc;
|
|
55
56
|
constructor(opts) {
|
|
56
57
|
this.opts = opts;
|
|
58
|
+
this._sessionId = opts.resumeSessionId;
|
|
57
59
|
}
|
|
58
60
|
/**
|
|
59
61
|
* Prepend role system text to a turn instruction when `systemPromptAppend` is
|
|
@@ -122,6 +124,9 @@ export class CodexAdapter {
|
|
|
122
124
|
if (!text && code !== 0) {
|
|
123
125
|
text = Buffer.concat(stderrChunks).toString().trim();
|
|
124
126
|
}
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
text = normalizeRuntimeErrorText("codex", text, code, "builder turn");
|
|
129
|
+
}
|
|
125
130
|
const result = {
|
|
126
131
|
text,
|
|
127
132
|
isError: code !== 0,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface GitCommandResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
}
|
|
4
|
+
export interface BaseWorktreeCleanOptions {
|
|
5
|
+
allowedDirtyPaths?: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function runGit(cwd: string, args: string[]): GitCommandResult;
|
|
8
|
+
export declare function currentGitRef(cwd: string): string;
|
|
9
|
+
export declare function generatedTrackerDirtyPaths(paths: {
|
|
10
|
+
stateDb: string;
|
|
11
|
+
progressDoc: string;
|
|
12
|
+
}): string[];
|
|
13
|
+
export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
|
|
14
|
+
export declare function ensureForemanExcluded(cwd: string): void;
|
|
15
|
+
export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
|
|
16
|
+
export declare function removeTicketWorktree(projectDir: string, worktreePath: string): void;
|
|
17
|
+
export declare function currentWorktreeBranch(worktreePath: string): string;
|
|
18
|
+
export declare function hasTrackerChanges(worktreePath: string): boolean;
|
|
19
|
+
export declare function hasWorktreeChanges(worktreePath: string): boolean;
|
|
20
|
+
export declare function commitAll(worktreePath: string, message: string): string | undefined;
|
|
21
|
+
export declare function headCommitIfAhead(worktreePath: string, base: string): string | undefined;
|
|
22
|
+
export declare function pushBranch(worktreePath: string, branch: string): void;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function runGit(cwd, args) {
|
|
5
|
+
const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
6
|
+
return { stdout: stdout.trim() };
|
|
7
|
+
}
|
|
8
|
+
export function currentGitRef(cwd) {
|
|
9
|
+
const branch = runGit(cwd, ["branch", "--show-current"]).stdout;
|
|
10
|
+
return branch || "HEAD";
|
|
11
|
+
}
|
|
12
|
+
export function generatedTrackerDirtyPaths(paths) {
|
|
13
|
+
const stateDb = normalizeRepoPath(paths.stateDb);
|
|
14
|
+
const progressDoc = normalizeRepoPath(paths.progressDoc);
|
|
15
|
+
return [
|
|
16
|
+
stateDb,
|
|
17
|
+
`${stateDb}-wal`,
|
|
18
|
+
`${stateDb}-shm`,
|
|
19
|
+
progressDoc,
|
|
20
|
+
`${progressDoc}.tmp`,
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
export function ensureCleanBaseWorktree(cwd, opts = {}) {
|
|
24
|
+
const allowedDirtyPaths = new Set((opts.allowedDirtyPaths ?? []).map(normalizeRepoPath));
|
|
25
|
+
const status = runGit(cwd, ["status", "--porcelain", "--untracked-files=all"]).stdout;
|
|
26
|
+
const dirty = status.split("\n").filter(Boolean).filter((line) => {
|
|
27
|
+
const path = normalizeRepoPath(line.slice(3));
|
|
28
|
+
return !path.startsWith(".foreman/") && !allowedDirtyPaths.has(path);
|
|
29
|
+
});
|
|
30
|
+
if (dirty.length > 0) {
|
|
31
|
+
throw new Error(`base worktree has uncommitted changes:\n${dirty.join("\n")}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function ensureForemanExcluded(cwd) {
|
|
35
|
+
const excludePath = join(cwd, ".git", "info", "exclude");
|
|
36
|
+
if (!existsSync(excludePath))
|
|
37
|
+
return;
|
|
38
|
+
const line = ".foreman/";
|
|
39
|
+
if (readFileSync(excludePath, "utf8").split(/\r?\n/).includes(line))
|
|
40
|
+
return;
|
|
41
|
+
appendFileSync(excludePath, `\n${line}\n`, "utf8");
|
|
42
|
+
}
|
|
43
|
+
export function createTicketWorktree(projectDir, runId, branch, base) {
|
|
44
|
+
const worktreePath = join(projectDir, ".foreman", "worktrees", runId, branch.replace(/\//g, "__"));
|
|
45
|
+
mkdirSync(dirname(worktreePath), { recursive: true });
|
|
46
|
+
runGit(projectDir, ["worktree", "add", "-b", branch, worktreePath, base]);
|
|
47
|
+
return worktreePath;
|
|
48
|
+
}
|
|
49
|
+
export function removeTicketWorktree(projectDir, worktreePath) {
|
|
50
|
+
try {
|
|
51
|
+
runGit(projectDir, ["worktree", "remove", "--force", worktreePath]);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function currentWorktreeBranch(worktreePath) {
|
|
58
|
+
return runGit(worktreePath, ["branch", "--show-current"]).stdout;
|
|
59
|
+
}
|
|
60
|
+
export function hasTrackerChanges(worktreePath) {
|
|
61
|
+
const status = runGit(worktreePath, ["status", "--porcelain", "--", ".tickets", "docs/ticket-progress.md", "ticket-progress.md"]).stdout;
|
|
62
|
+
return status.trim().length > 0;
|
|
63
|
+
}
|
|
64
|
+
export function hasWorktreeChanges(worktreePath) {
|
|
65
|
+
return runGit(worktreePath, ["status", "--porcelain"]).stdout.trim().length > 0;
|
|
66
|
+
}
|
|
67
|
+
export function commitAll(worktreePath, message) {
|
|
68
|
+
if (!hasWorktreeChanges(worktreePath))
|
|
69
|
+
return undefined;
|
|
70
|
+
runGit(worktreePath, ["add", "-A"]);
|
|
71
|
+
runGit(worktreePath, ["commit", "-m", message]);
|
|
72
|
+
return runGit(worktreePath, ["rev-parse", "--short", "HEAD"]).stdout;
|
|
73
|
+
}
|
|
74
|
+
export function headCommitIfAhead(worktreePath, base) {
|
|
75
|
+
try {
|
|
76
|
+
const count = Number.parseInt(runGit(worktreePath, ["rev-list", "--count", `${base}..HEAD`]).stdout, 10);
|
|
77
|
+
if (!Number.isInteger(count) || count < 1)
|
|
78
|
+
return undefined;
|
|
79
|
+
return runGit(worktreePath, ["rev-parse", "--short", "HEAD"]).stdout;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function pushBranch(worktreePath, branch) {
|
|
86
|
+
runGit(worktreePath, ["push", "-u", "origin", branch]);
|
|
87
|
+
}
|
|
88
|
+
function normalizeRepoPath(path) {
|
|
89
|
+
return path
|
|
90
|
+
.replace(/\\/g, "/")
|
|
91
|
+
.replace(/^\.\/+/, "")
|
|
92
|
+
.replace(/\/+/g, "/")
|
|
93
|
+
.replace(/\/$/, "");
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BranchPlanNode, GitHubFailure, GitHubOperationResult, GitHubReadinessResult, GitHubRemote, GitHubRemoteResult, PrResult } from "./types.js";
|
|
2
|
+
export declare function inspectGitHubRemote(cwd: string): GitHubRemoteResult;
|
|
3
|
+
export declare function originLooksLikeGitHub(cwd: string): boolean;
|
|
4
|
+
export declare function checkGitHubReadiness(cwd: string): GitHubReadinessResult;
|
|
5
|
+
export declare function preflightGh(cwd: string): GitHubReadinessResult;
|
|
6
|
+
export declare function pushBranchForPr(cwd: string, branch: string): GitHubOperationResult;
|
|
7
|
+
interface ExistingPrResult {
|
|
8
|
+
ok: true;
|
|
9
|
+
url?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function findExistingPr(cwd: string, branch: string, remote?: GitHubRemote): ExistingPrResult | GitHubFailure;
|
|
12
|
+
export interface CreatePrOptions {
|
|
13
|
+
node: BranchPlanNode;
|
|
14
|
+
ready: boolean;
|
|
15
|
+
runId: string;
|
|
16
|
+
qaEvidence?: string;
|
|
17
|
+
commit?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function createOrReusePr(cwd: string, opts: CreatePrOptions): PrResult;
|
|
20
|
+
export declare function formatGitHubFailure(failure: GitHubFailure): string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
|
|
5
|
+
const OUTPUT_LIMIT = 2_000;
|
|
6
|
+
function runCommand(cwd, bin, args, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
|
|
7
|
+
const result = spawnSync(bin, args, {
|
|
8
|
+
cwd,
|
|
9
|
+
encoding: "utf8",
|
|
10
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
11
|
+
timeout: commandTimeoutMs(timeoutMs),
|
|
12
|
+
});
|
|
13
|
+
const stdout = outputToString(result.stdout).trim();
|
|
14
|
+
const stderr = outputToString(result.stderr).trim();
|
|
15
|
+
const output = truncateOutput([stderr, stdout].filter(Boolean).join("\n").trim());
|
|
16
|
+
const error = result.error;
|
|
17
|
+
return {
|
|
18
|
+
ok: !error && result.status === 0,
|
|
19
|
+
command: commandDisplay(bin, args),
|
|
20
|
+
stdout,
|
|
21
|
+
stderr,
|
|
22
|
+
output,
|
|
23
|
+
status: result.status,
|
|
24
|
+
signal: result.signal,
|
|
25
|
+
errorCode: error?.code,
|
|
26
|
+
errorMessage: error?.message,
|
|
27
|
+
timedOut: error?.code === "ETIMEDOUT" || result.signal === "SIGTERM",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function commandTimeoutMs(defaultMs) {
|
|
31
|
+
const raw = process.env.RAFI_GITHUB_COMMAND_TIMEOUT_MS;
|
|
32
|
+
if (!raw)
|
|
33
|
+
return defaultMs;
|
|
34
|
+
const parsed = Number.parseInt(raw, 10);
|
|
35
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultMs;
|
|
36
|
+
}
|
|
37
|
+
export function inspectGitHubRemote(cwd) {
|
|
38
|
+
const remote = runCommand(cwd, "git", ["remote", "get-url", "origin"]);
|
|
39
|
+
if (!remote.ok) {
|
|
40
|
+
return failure("remote_missing", "git remote origin is not configured.", [
|
|
41
|
+
"git remote add origin <github repo url>",
|
|
42
|
+
"git remote get-url origin",
|
|
43
|
+
], remote);
|
|
44
|
+
}
|
|
45
|
+
return parseGitHubRemote(remote.stdout);
|
|
46
|
+
}
|
|
47
|
+
export function originLooksLikeGitHub(cwd) {
|
|
48
|
+
const remote = inspectGitHubRemote(cwd);
|
|
49
|
+
return remote.ok && remote.remote.likelyGitHub;
|
|
50
|
+
}
|
|
51
|
+
export function checkGitHubReadiness(cwd) {
|
|
52
|
+
const ghVersion = runCommand(cwd, "gh", ["--version"], 5_000);
|
|
53
|
+
if (!ghVersion.ok) {
|
|
54
|
+
return failure("gh_missing", "GitHub CLI is not installed or is not on PATH.", [
|
|
55
|
+
"Install GitHub CLI: https://cli.github.com/",
|
|
56
|
+
"gh --version",
|
|
57
|
+
], ghVersion);
|
|
58
|
+
}
|
|
59
|
+
const remote = inspectGitHubRemote(cwd);
|
|
60
|
+
if (!remote.ok)
|
|
61
|
+
return remote;
|
|
62
|
+
const auth = runCommand(cwd, "gh", ["auth", "status", "--hostname", remote.remote.host], 10_000);
|
|
63
|
+
if (!auth.ok) {
|
|
64
|
+
return classifyCommandFailure(auth, "gh_not_authenticated", authMessage(remote.remote), authRepair(remote.remote));
|
|
65
|
+
}
|
|
66
|
+
const repoView = runCommand(cwd, "gh", [
|
|
67
|
+
"repo",
|
|
68
|
+
"view",
|
|
69
|
+
remote.remote.repoArg,
|
|
70
|
+
"--json",
|
|
71
|
+
"nameWithOwner",
|
|
72
|
+
"--jq",
|
|
73
|
+
".nameWithOwner",
|
|
74
|
+
]);
|
|
75
|
+
if (!repoView.ok) {
|
|
76
|
+
return classifyCommandFailure(repoView, "repo_unreachable", repoMessage(remote.remote), [
|
|
77
|
+
`gh repo view ${remote.remote.repoArg}`,
|
|
78
|
+
...authRepair(remote.remote),
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
const lsRemote = runCommand(cwd, "git", ["ls-remote", "origin"]);
|
|
82
|
+
if (!lsRemote.ok) {
|
|
83
|
+
return classifyCommandFailure(lsRemote, "git_remote_unreachable", [
|
|
84
|
+
"git cannot reach the origin remote. Check SSH/token credentials, VPN, network access, and repository permissions.",
|
|
85
|
+
`Origin: ${remote.remote.remoteUrl}`,
|
|
86
|
+
].join(" "), [
|
|
87
|
+
"git ls-remote origin",
|
|
88
|
+
"git remote -v",
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, remote: remote.remote };
|
|
92
|
+
}
|
|
93
|
+
export function preflightGh(cwd) {
|
|
94
|
+
return checkGitHubReadiness(cwd);
|
|
95
|
+
}
|
|
96
|
+
export function pushBranchForPr(cwd, branch) {
|
|
97
|
+
const result = runCommand(cwd, "git", ["push", "-u", "origin", branch], 30_000);
|
|
98
|
+
if (result.ok)
|
|
99
|
+
return { ok: true, output: result.stdout };
|
|
100
|
+
return classifyCommandFailure(result, "push_failed", [
|
|
101
|
+
`Failed to push branch ${branch} to origin.`,
|
|
102
|
+
"Branch push requires write access and working Git credentials for the remote.",
|
|
103
|
+
].join(" "), [
|
|
104
|
+
`git push -u origin ${branch}`,
|
|
105
|
+
"git ls-remote origin",
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
export function findExistingPr(cwd, branch, remote) {
|
|
109
|
+
const result = runCommand(cwd, "gh", [
|
|
110
|
+
"pr",
|
|
111
|
+
"list",
|
|
112
|
+
"--head",
|
|
113
|
+
branch,
|
|
114
|
+
"--state",
|
|
115
|
+
"open",
|
|
116
|
+
"--json",
|
|
117
|
+
"url",
|
|
118
|
+
"--jq",
|
|
119
|
+
".[0].url",
|
|
120
|
+
]);
|
|
121
|
+
if (result.ok)
|
|
122
|
+
return { ok: true, url: result.stdout || undefined };
|
|
123
|
+
return classifyPrFailure(result, branch, "Failed to check for an existing GitHub PR.", remote);
|
|
124
|
+
}
|
|
125
|
+
export function createOrReusePr(cwd, opts) {
|
|
126
|
+
const remote = inspectGitHubRemote(cwd);
|
|
127
|
+
const remoteForRepair = remote.ok ? remote.remote : undefined;
|
|
128
|
+
const existing = findExistingPr(cwd, opts.node.branch, remoteForRepair);
|
|
129
|
+
if (!existing.ok)
|
|
130
|
+
return prFailure(existing);
|
|
131
|
+
if (existing.url)
|
|
132
|
+
return { status: "existing", url: existing.url };
|
|
133
|
+
const bodyPath = join(cwd, ".foreman", "pr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
|
|
134
|
+
try {
|
|
135
|
+
mkdirSync(join(cwd, ".foreman", "pr-bodies", opts.runId), { recursive: true });
|
|
136
|
+
writeFileSync(bodyPath, buildPrBody(opts), "utf8");
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return prFailure(prBodyWriteFailure(opts.runId, bodyPath, err));
|
|
140
|
+
}
|
|
141
|
+
const args = [
|
|
142
|
+
"pr",
|
|
143
|
+
"create",
|
|
144
|
+
"--base",
|
|
145
|
+
opts.node.baseBranch,
|
|
146
|
+
"--head",
|
|
147
|
+
opts.node.branch,
|
|
148
|
+
"--title",
|
|
149
|
+
`${opts.node.ticket.id}: ${opts.node.ticket.title}`,
|
|
150
|
+
"--body-file",
|
|
151
|
+
bodyPath,
|
|
152
|
+
];
|
|
153
|
+
if (!opts.ready)
|
|
154
|
+
args.push("--draft");
|
|
155
|
+
const created = runCommand(cwd, "gh", args);
|
|
156
|
+
if (created.ok)
|
|
157
|
+
return { status: "created", url: created.stdout };
|
|
158
|
+
return prFailure(classifyPrFailure(created, opts.node.branch, "Failed to create GitHub PR.", remoteForRepair));
|
|
159
|
+
}
|
|
160
|
+
export function formatGitHubFailure(failure) {
|
|
161
|
+
const lines = [
|
|
162
|
+
`${failure.code}: ${failure.message}`,
|
|
163
|
+
"Repair and verify:",
|
|
164
|
+
...failure.repairCommands.map((command) => ` ${command}`),
|
|
165
|
+
];
|
|
166
|
+
if (failure.output) {
|
|
167
|
+
lines.push("", "Command output:", indent(failure.output));
|
|
168
|
+
}
|
|
169
|
+
else if (failure.command) {
|
|
170
|
+
lines.push("", `Command: ${failure.command}`);
|
|
171
|
+
}
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
174
|
+
function parseGitHubRemote(remoteUrl) {
|
|
175
|
+
const parsed = parseRemoteParts(remoteUrl.trim());
|
|
176
|
+
if (!parsed) {
|
|
177
|
+
return failure("remote_not_github", `origin does not look like a GitHub remote: ${remoteUrl}`, [
|
|
178
|
+
"git remote set-url origin <github repo url>",
|
|
179
|
+
"git remote get-url origin",
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
const repo = parsed.repo.replace(/\.git$/i, "");
|
|
183
|
+
if (!parsed.owner || !repo || isKnownNonGitHubHost(parsed.host)) {
|
|
184
|
+
return failure("remote_not_github", `origin does not look like a GitHub remote: ${remoteUrl}`, [
|
|
185
|
+
"git remote set-url origin <github repo url>",
|
|
186
|
+
"git remote get-url origin",
|
|
187
|
+
]);
|
|
188
|
+
}
|
|
189
|
+
const likelyGitHub = isLikelyGitHubHost(parsed.host);
|
|
190
|
+
const repoArg = parsed.host === "github.com"
|
|
191
|
+
? `${parsed.owner}/${repo}`
|
|
192
|
+
: `${parsed.host}/${parsed.owner}/${repo}`;
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
remote: {
|
|
196
|
+
remoteUrl,
|
|
197
|
+
host: parsed.host,
|
|
198
|
+
owner: parsed.owner,
|
|
199
|
+
repo,
|
|
200
|
+
repoArg,
|
|
201
|
+
likelyGitHub,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function parseRemoteParts(remoteUrl) {
|
|
206
|
+
const scpLike = /^(?:[^@/\s]+@)?([^:/\s]+):([^/\s]+)\/(.+)$/.exec(remoteUrl);
|
|
207
|
+
if (scpLike) {
|
|
208
|
+
return {
|
|
209
|
+
host: scpLike[1].toLowerCase(),
|
|
210
|
+
owner: scpLike[2],
|
|
211
|
+
repo: lastPathPart(scpLike[3]),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(remoteUrl);
|
|
216
|
+
if (!url.hostname)
|
|
217
|
+
return undefined;
|
|
218
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
219
|
+
if (parts.length < 2)
|
|
220
|
+
return undefined;
|
|
221
|
+
return {
|
|
222
|
+
host: url.hostname.toLowerCase(),
|
|
223
|
+
owner: parts[0],
|
|
224
|
+
repo: lastPathPart(parts.slice(1).join("/")),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function lastPathPart(path) {
|
|
232
|
+
const parts = path.split("/").filter(Boolean);
|
|
233
|
+
return parts[parts.length - 1] ?? "";
|
|
234
|
+
}
|
|
235
|
+
function classifyPrFailure(result, branch, message, remote) {
|
|
236
|
+
if (looksUnauthenticated(result.output)) {
|
|
237
|
+
return failure("gh_not_authenticated", "GitHub CLI is not authenticated for PR operations.", prRepair(branch, remote), result);
|
|
238
|
+
}
|
|
239
|
+
return classifyCommandFailure(result, "pr_create_failed", `${message} Branch: ${branch}`, prRepair(branch, remote));
|
|
240
|
+
}
|
|
241
|
+
function classifyCommandFailure(result, fallbackCode, fallbackMessage, repairCommands) {
|
|
242
|
+
if (result.errorCode === "ENOENT") {
|
|
243
|
+
const command = result.command.startsWith("gh ") ? "GitHub CLI" : result.command.split(" ")[0];
|
|
244
|
+
return failure(result.command.startsWith("gh ") ? "gh_missing" : "unknown", `${command} is not installed or is not on PATH.`, result.command.startsWith("gh ")
|
|
245
|
+
? ["Install GitHub CLI: https://cli.github.com/", "gh --version"]
|
|
246
|
+
: repairCommands, result);
|
|
247
|
+
}
|
|
248
|
+
if (looksRemoteNotGitHub(result.output)) {
|
|
249
|
+
return failure("remote_not_github", `${fallbackMessage} The origin host is not a GitHub or GitHub Enterprise host.`, repairCommands, result);
|
|
250
|
+
}
|
|
251
|
+
if (looksGhRepoResolutionFailure(result.output)) {
|
|
252
|
+
return failure("repo_unreachable", fallbackMessage, repairCommands, result);
|
|
253
|
+
}
|
|
254
|
+
if (result.timedOut || looksNetworkLike(result.output) || looksNetworkLike(result.errorMessage ?? "")) {
|
|
255
|
+
return failure("network_or_timeout", `${fallbackMessage} The command timed out or hit a network error.`, repairCommands, result);
|
|
256
|
+
}
|
|
257
|
+
if (fallbackCode === "gh_not_authenticated" || looksUnauthenticated(result.output)) {
|
|
258
|
+
return failure("gh_not_authenticated", fallbackMessage, repairCommands, result);
|
|
259
|
+
}
|
|
260
|
+
return failure(fallbackCode, fallbackMessage, repairCommands, result);
|
|
261
|
+
}
|
|
262
|
+
function prBodyWriteFailure(runId, bodyPath, err) {
|
|
263
|
+
const bodyDir = join(".foreman", "pr-bodies", runId);
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
code: "pr_create_failed",
|
|
267
|
+
message: `Failed to write GitHub PR body file at ${bodyPath}.`,
|
|
268
|
+
repairCommands: [
|
|
269
|
+
`ls -la ${shellQuote(join(".foreman", "pr-bodies"))}`,
|
|
270
|
+
`mkdir -p ${shellQuote(bodyDir)}`,
|
|
271
|
+
],
|
|
272
|
+
output: err instanceof Error ? err.message : String(err),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function failure(code, message, repairCommands, result) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
code,
|
|
279
|
+
message,
|
|
280
|
+
repairCommands,
|
|
281
|
+
command: result?.command,
|
|
282
|
+
output: result?.output || result?.errorMessage ? truncateOutput([result?.output, result?.errorMessage].filter(Boolean).join("\n")) : undefined,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function prFailure(failure) {
|
|
286
|
+
return {
|
|
287
|
+
status: "failed",
|
|
288
|
+
error: failure.message,
|
|
289
|
+
code: failure.code,
|
|
290
|
+
message: failure.message,
|
|
291
|
+
repairCommands: failure.repairCommands,
|
|
292
|
+
command: failure.command,
|
|
293
|
+
output: failure.output,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function authMessage(remote) {
|
|
297
|
+
return `GitHub CLI is not authenticated for ${remote.host}.`;
|
|
298
|
+
}
|
|
299
|
+
function repoMessage(remote) {
|
|
300
|
+
return `GitHub CLI cannot view ${remote.repoArg}. Check repository existence and read/write permissions.`;
|
|
301
|
+
}
|
|
302
|
+
function authRepair(remote) {
|
|
303
|
+
if (remote.host === "github.com") {
|
|
304
|
+
return ["gh auth login", "gh auth status"];
|
|
305
|
+
}
|
|
306
|
+
return [
|
|
307
|
+
`gh auth login --hostname ${remote.host}`,
|
|
308
|
+
`gh auth status --hostname ${remote.host}`,
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
function authRepairForPr(remote) {
|
|
312
|
+
return remote ? authRepair(remote) : ["gh auth login", "gh auth status"];
|
|
313
|
+
}
|
|
314
|
+
function prRepair(branch, remote) {
|
|
315
|
+
return [
|
|
316
|
+
`gh pr list --head ${branch} --state open`,
|
|
317
|
+
...authRepairForPr(remote),
|
|
318
|
+
remote ? `gh repo view ${remote.repoArg}` : "gh repo view",
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
function isLikelyGitHubHost(host) {
|
|
322
|
+
return host === "github.com" || host.includes("github.");
|
|
323
|
+
}
|
|
324
|
+
function isKnownNonGitHubHost(host) {
|
|
325
|
+
return [
|
|
326
|
+
"gitlab.com",
|
|
327
|
+
"bitbucket.org",
|
|
328
|
+
"codeberg.org",
|
|
329
|
+
"sr.ht",
|
|
330
|
+
"sourcehut.org",
|
|
331
|
+
"dev.azure.com",
|
|
332
|
+
"ssh.dev.azure.com",
|
|
333
|
+
].includes(host);
|
|
334
|
+
}
|
|
335
|
+
function looksUnauthenticated(output) {
|
|
336
|
+
return [
|
|
337
|
+
/not logged in/i,
|
|
338
|
+
/authentication required/i,
|
|
339
|
+
/could not authenticate/i,
|
|
340
|
+
/bad credentials/i,
|
|
341
|
+
/\b401\b/,
|
|
342
|
+
/unauthorized/i,
|
|
343
|
+
].some((pattern) => pattern.test(output));
|
|
344
|
+
}
|
|
345
|
+
function looksNetworkLike(output) {
|
|
346
|
+
return [
|
|
347
|
+
/timed? out/i,
|
|
348
|
+
/could not resolve host/i,
|
|
349
|
+
/couldn't resolve host/i,
|
|
350
|
+
/failed to connect/i,
|
|
351
|
+
/connection (?:reset|refused|closed)/i,
|
|
352
|
+
/network is unreachable/i,
|
|
353
|
+
/no route to host/i,
|
|
354
|
+
/name or service not known/i,
|
|
355
|
+
/temporary failure/i,
|
|
356
|
+
/\b(?:ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EAI_AGAIN|EHOSTUNREACH)\b/i,
|
|
357
|
+
/TLS handshake timeout/i,
|
|
358
|
+
].some((pattern) => pattern.test(output));
|
|
359
|
+
}
|
|
360
|
+
function looksGhRepoResolutionFailure(output) {
|
|
361
|
+
return /could not resolve to a Repository/i.test(output);
|
|
362
|
+
}
|
|
363
|
+
function looksRemoteNotGitHub(output) {
|
|
364
|
+
return [
|
|
365
|
+
/not a GitHub(?: Enterprise Server)? host/i,
|
|
366
|
+
/not a GitHub or GitHub Enterprise host/i,
|
|
367
|
+
/not a GitHub Enterprise Server/i,
|
|
368
|
+
/hostname .*is not.*github/i,
|
|
369
|
+
].some((pattern) => pattern.test(output));
|
|
370
|
+
}
|
|
371
|
+
function commandDisplay(bin, args) {
|
|
372
|
+
return [bin, ...args.map(shellQuote)].join(" ");
|
|
373
|
+
}
|
|
374
|
+
function shellQuote(value) {
|
|
375
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
376
|
+
return value;
|
|
377
|
+
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
378
|
+
}
|
|
379
|
+
function outputToString(value) {
|
|
380
|
+
if (!value)
|
|
381
|
+
return "";
|
|
382
|
+
return Buffer.isBuffer(value) ? value.toString("utf8") : value;
|
|
383
|
+
}
|
|
384
|
+
function truncateOutput(output) {
|
|
385
|
+
if (output.length <= OUTPUT_LIMIT)
|
|
386
|
+
return output;
|
|
387
|
+
return `${output.slice(0, OUTPUT_LIMIT).trimEnd()}\n... truncated ...`;
|
|
388
|
+
}
|
|
389
|
+
function indent(value) {
|
|
390
|
+
return value.split("\n").map((line) => ` ${line}`).join("\n");
|
|
391
|
+
}
|
|
392
|
+
function buildPrBody(opts) {
|
|
393
|
+
const ticket = opts.node.ticket;
|
|
394
|
+
return [
|
|
395
|
+
`## ${ticket.id}: ${ticket.title}`,
|
|
396
|
+
"",
|
|
397
|
+
ticket.summary,
|
|
398
|
+
"",
|
|
399
|
+
"## Acceptance Criteria",
|
|
400
|
+
...ticket.acceptance.map((item) => `- ${item}`),
|
|
401
|
+
"",
|
|
402
|
+
"## Validation",
|
|
403
|
+
opts.qaEvidence ?? "Foreman QA emitted qa_pass.",
|
|
404
|
+
"",
|
|
405
|
+
"## Branch Metadata",
|
|
406
|
+
`- Base: ${opts.node.baseBranch}`,
|
|
407
|
+
`- Head: ${opts.node.branch}`,
|
|
408
|
+
`- Dependencies: ${opts.node.dependencies.length ? opts.node.dependencies.join(", ") : "None"}`,
|
|
409
|
+
`- Commit: ${opts.commit ?? "N/A"}`,
|
|
410
|
+
"",
|
|
411
|
+
"Generated by Rafi ai-foreman branch-per-ticket mode.",
|
|
412
|
+
"",
|
|
413
|
+
].join("\n");
|
|
414
|
+
}
|