ai-foreman 1.0.10 → 1.0.12
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 +45 -2
- package/dist/adapters/codex.js +1 -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/start.js +272 -18
- package/dist/cli/status.js +148 -2
- package/dist/config.js +1 -0
- package/dist/foreman.d.ts +6 -0
- package/dist/foreman.js +4 -0
- package/dist/log.d.ts +1 -1
- package/foreman.yaml +3 -1
- 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,43 @@ 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
|
+
```
|
|
126
|
+
|
|
127
|
+
### GitHub PR Failure Recovery
|
|
128
|
+
|
|
129
|
+
When `--create-pr` cannot prepare GitHub, push a branch, or create a PR, Foreman records a structured failure and blocks the affected ticket cleanly. Blocked worktrees are retained automatically so you can repair the environment and retry without losing the ticket session.
|
|
130
|
+
|
|
131
|
+
`ai-foreman status ./my-project` shows the latest GitHub failure code, repair commands, last command output, and a retry command when a resumable ticket session exists.
|
|
132
|
+
|
|
133
|
+
Common failure codes:
|
|
134
|
+
|
|
135
|
+
| Code | Meaning |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `gh_missing` | GitHub CLI is not installed or not on `PATH`. |
|
|
138
|
+
| `gh_not_authenticated` | `gh` is installed but not authenticated for the remote host. |
|
|
139
|
+
| `repo_unreachable` | `gh repo view` cannot access the target repository. |
|
|
140
|
+
| `git_remote_unreachable` | `git ls-remote` cannot access the configured remote. |
|
|
141
|
+
| `network_or_timeout` | A GitHub or git command timed out or hit a network error. |
|
|
142
|
+
| `push_failed` | The ticket branch could not be pushed. |
|
|
143
|
+
| `pr_create_failed` | The branch pushed, but PR creation or PR body writing failed. |
|
|
144
|
+
|
|
145
|
+
For GitHub Enterprise remotes, repair commands include the remote host:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
gh auth login --hostname <host>
|
|
149
|
+
gh auth status --hostname <host>
|
|
150
|
+
gh repo view <host>/<owner>/<repo>
|
|
151
|
+
```
|
|
107
152
|
|
|
108
153
|
### `ai-foreman tickets populate`
|
|
109
154
|
|
|
@@ -522,5 +567,3 @@ Package output:
|
|
|
522
567
|
- **`special-agents`** — library (rules + skills + agents + composition)
|
|
523
568
|
- **`ai-foreman`** — this runtime
|
|
524
569
|
- **`@rafi-ai/cli`** — CLI for `rafi create` and `rafi compile`
|
|
525
|
-
|
|
526
|
-
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TicketDef } from "../tickets/ticketSchema.js";
|
|
2
|
+
import type { TicketState } from "../tickets/stateDb.js";
|
|
3
|
+
import type { BranchPlan } from "./types.js";
|
|
4
|
+
export interface AuditDependency {
|
|
5
|
+
ticket: string;
|
|
6
|
+
dependsOn: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildBranchPlanOptions {
|
|
10
|
+
steps: number;
|
|
11
|
+
baseRef: string;
|
|
12
|
+
branchPrefix: string;
|
|
13
|
+
maxBranchDepth: number;
|
|
14
|
+
auditDependencies?: AuditDependency[];
|
|
15
|
+
}
|
|
16
|
+
export declare function buildBranchPlan(tickets: TicketDef[], states: Map<string, TicketState>, opts: BuildBranchPlanOptions): BranchPlan;
|
|
17
|
+
export declare function selectTicketsForBranchRun(tickets: TicketDef[], states: Map<string, TicketState>, steps: number): TicketDef[];
|
|
18
|
+
export declare function parseAuditDependencies(text: string): AuditDependency[];
|
|
19
|
+
export declare function buildBranchAuditInstruction(tickets: TicketDef[]): string;
|