ai-foreman 1.3.0 → 1.4.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 +13 -1
- package/dist/branch/git.d.ts +2 -0
- package/dist/branch/git.js +18 -1
- package/dist/branch/github.d.ts +3 -1
- package/dist/branch/github.js +32 -0
- package/dist/branch/gitlab.d.ts +15 -0
- package/dist/branch/gitlab.js +290 -0
- package/dist/branch/planner.d.ts +1 -0
- package/dist/branch/planner.js +3 -1
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +7 -1
- package/dist/branch/runner.d.ts +6 -1
- package/dist/branch/runner.js +155 -20
- package/dist/branch/types.d.ts +10 -2
- package/dist/cli/start.js +135 -7
- package/dist/cli/tickets.d.ts +11 -0
- package/dist/cli/tickets.js +736 -92
- package/dist/foreman.js +2 -2
- package/dist/log.d.ts +1 -1
- package/dist/tickets/commands.d.ts +6 -0
- package/dist/tickets/commands.js +40 -3
- package/dist/tickets/config.d.ts +2 -1
- package/dist/tickets/config.js +20 -4
- package/dist/tickets/importer.d.ts +55 -0
- package/dist/tickets/importer.js +480 -0
- package/dist/tickets/recommendations.d.ts +32 -0
- package/dist/tickets/recommendations.js +183 -0
- package/dist/tickets/renderMarkdown.js +26 -2
- package/dist/tickets/setupConfig.d.ts +77 -0
- package/dist/tickets/setupConfig.js +369 -0
- package/dist/tickets/stateDb.d.ts +17 -0
- package/dist/tickets/stateDb.js +46 -0
- package/dist/tickets/ticketSchema.d.ts +55 -0
- package/dist/tickets/ticketSchema.js +14 -0
- package/dist/tickets/validate.js +2 -2
- package/package.json +2 -2
package/dist/branch/runner.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Foreman, MARKER_SPEC } from "../foreman.js";
|
|
2
2
|
import { fireNotification } from "../notify.js";
|
|
3
3
|
import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
|
|
4
|
-
import { commitAll, createTicketWorktree, currentWorktreeBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, } from "./git.js";
|
|
5
|
-
import { createOrReusePr, pushBranchForPr } from "./github.js";
|
|
4
|
+
import { commitAll, createTicketWorktree, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, squashMergeBranchToLocalBase, } from "./git.js";
|
|
5
|
+
import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
|
|
6
|
+
import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
|
|
6
7
|
export async function runBranchPlan(opts) {
|
|
7
8
|
ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
|
|
8
9
|
ensureForemanExcluded(opts.projectDir);
|
|
10
|
+
const completionMode = opts.completionMode ?? (opts.createPr ? "pr" : "none");
|
|
11
|
+
const reviewProvider = opts.reviewProvider ?? "github";
|
|
12
|
+
const createsReview = completionMode === "pr" || completionMode === "auto-merge";
|
|
9
13
|
const summaries = [];
|
|
10
14
|
const successfulBranches = new Set();
|
|
11
15
|
const pushedBranches = new Set();
|
|
@@ -38,7 +42,7 @@ export async function runBranchPlan(opts) {
|
|
|
38
42
|
notifyIssue(opts.notificationsEnabled, detail);
|
|
39
43
|
continue;
|
|
40
44
|
}
|
|
41
|
-
if (
|
|
45
|
+
if (createsReview) {
|
|
42
46
|
const missingPush = node.dependencies.find((dep) => {
|
|
43
47
|
const depNode = opts.plan.nodes.find((candidate) => candidate.ticket.id === dep);
|
|
44
48
|
return depNode && !pushedBranches.has(depNode.branch);
|
|
@@ -56,6 +60,23 @@ export async function runBranchPlan(opts) {
|
|
|
56
60
|
continue;
|
|
57
61
|
}
|
|
58
62
|
}
|
|
63
|
+
if (completionMode === "auto-merge" && node.dependencies.length > 0) {
|
|
64
|
+
const mergeReadiness = await waitForAutoMergeDependencies(opts, node, reviewProvider);
|
|
65
|
+
if (!mergeReadiness.ok) {
|
|
66
|
+
summaries.push(summaryFor(node, "skipped", mergeReadiness.message, "skipped"));
|
|
67
|
+
opts.log.write("branch-issue", {
|
|
68
|
+
ticket: node.ticket.id,
|
|
69
|
+
code: mergeReadiness.code,
|
|
70
|
+
message: mergeReadiness.message,
|
|
71
|
+
blocking: false,
|
|
72
|
+
repairCommands: mergeReadiness.repairCommands,
|
|
73
|
+
command: mergeReadiness.command,
|
|
74
|
+
output: mergeReadiness.output,
|
|
75
|
+
});
|
|
76
|
+
notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${mergeReadiness.message}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
59
80
|
opts.log.write("branch-start", {
|
|
60
81
|
ticket: node.ticket.id,
|
|
61
82
|
branch: node.branch,
|
|
@@ -100,7 +121,9 @@ export async function runBranchPlan(opts) {
|
|
|
100
121
|
effort: opts.effort,
|
|
101
122
|
fast: opts.fast,
|
|
102
123
|
qaEnabled: opts.qaEnabled,
|
|
103
|
-
createPr:
|
|
124
|
+
createPr: createsReview,
|
|
125
|
+
completionMode,
|
|
126
|
+
reviewProvider,
|
|
104
127
|
prReady: opts.prReady,
|
|
105
128
|
keepWorktrees: opts.keepWorktrees,
|
|
106
129
|
});
|
|
@@ -157,13 +180,15 @@ export async function runBranchPlan(opts) {
|
|
|
157
180
|
if (hasWorktreeChanges(worktreePath)) {
|
|
158
181
|
commit = commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`);
|
|
159
182
|
}
|
|
160
|
-
if (!commit &&
|
|
183
|
+
if (!commit && (createsReview || completionMode === "direct-merge")) {
|
|
161
184
|
commit = headCommitIfAhead(worktreePath, node.baseBranch);
|
|
162
185
|
}
|
|
163
186
|
const summary = summaryFor(node, "done", commit ? undefined : "no_changes");
|
|
164
187
|
summary.commit = commit;
|
|
165
|
-
if (commit &&
|
|
166
|
-
const push =
|
|
188
|
+
if (commit && createsReview) {
|
|
189
|
+
const push = reviewProvider === "gitlab"
|
|
190
|
+
? pushBranchForMr(worktreePath, node.branch)
|
|
191
|
+
: pushBranchForPr(worktreePath, node.branch);
|
|
167
192
|
if (push.ok) {
|
|
168
193
|
opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
|
|
169
194
|
summary.pushStatus = "pushed";
|
|
@@ -194,29 +219,75 @@ export async function runBranchPlan(opts) {
|
|
|
194
219
|
continue;
|
|
195
220
|
}
|
|
196
221
|
pushedBranches.add(node.branch);
|
|
197
|
-
const pr =
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
222
|
+
const pr = reviewProvider === "gitlab"
|
|
223
|
+
? createOrReuseMr(opts.projectDir, {
|
|
224
|
+
node,
|
|
225
|
+
ready: opts.prReady || completionMode === "auto-merge",
|
|
226
|
+
runId: opts.runId,
|
|
227
|
+
qaEvidence: qaSummary,
|
|
228
|
+
commit,
|
|
229
|
+
autoMerge: completionMode === "auto-merge",
|
|
230
|
+
cleanup: opts.cleanupBranches ?? true,
|
|
231
|
+
})
|
|
232
|
+
: createOrReusePr(opts.projectDir, {
|
|
233
|
+
node,
|
|
234
|
+
ready: opts.prReady || completionMode === "auto-merge",
|
|
235
|
+
runId: opts.runId,
|
|
236
|
+
qaEvidence: qaSummary,
|
|
237
|
+
commit,
|
|
238
|
+
});
|
|
204
239
|
summary.pr = pr;
|
|
205
240
|
if (pr.status === "created")
|
|
206
|
-
opts.log.write("pr-created", { ticket: node.ticket.id, branch: node.branch, url: pr.url });
|
|
241
|
+
opts.log.write(reviewProvider === "gitlab" ? "mr-created" : "pr-created", { ticket: node.ticket.id, branch: node.branch, url: pr.url });
|
|
207
242
|
if (pr.status === "existing")
|
|
208
|
-
opts.log.write("pr-existing", { ticket: node.ticket.id, branch: node.branch, url: pr.url });
|
|
243
|
+
opts.log.write(reviewProvider === "gitlab" ? "mr-existing" : "pr-existing", { ticket: node.ticket.id, branch: node.branch, url: pr.url });
|
|
209
244
|
if (pr.status === "failed") {
|
|
210
|
-
opts.log.write("pr-failed", failureLogFields(node, pr));
|
|
211
|
-
blockBranchIssue(opts, node, summary, pr.code ?? "pr_create_failed",
|
|
245
|
+
opts.log.write(reviewProvider === "gitlab" ? "mr-failed" : "pr-failed", failureLogFields(node, pr));
|
|
246
|
+
blockBranchIssue(opts, node, summary, pr.code ?? (reviewProvider === "gitlab" ? "mr_create_failed" : "pr_create_failed"), `${reviewProvider === "gitlab" ? "MR" : "PR"} creation failed: ${pr.message ?? pr.error ?? "unknown error"}`, pr);
|
|
212
247
|
summaries.push(summary);
|
|
213
248
|
continue;
|
|
214
249
|
}
|
|
250
|
+
if (completionMode === "auto-merge" && reviewProvider === "github") {
|
|
251
|
+
const autoMerge = enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true);
|
|
252
|
+
summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
|
|
253
|
+
if (autoMerge.status === "failed") {
|
|
254
|
+
opts.log.write("pr-auto-merge-failed", failureLogFields(node, autoMerge));
|
|
255
|
+
blockBranchIssue(opts, node, summary, autoMerge.code ?? "pr_create_failed", `PR auto-merge failed: ${autoMerge.message ?? autoMerge.error ?? "unknown error"}`, autoMerge);
|
|
256
|
+
summaries.push(summary);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
opts.log.write("pr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
|
|
260
|
+
}
|
|
261
|
+
else if (completionMode === "auto-merge" && reviewProvider === "gitlab" && pr.status === "existing") {
|
|
262
|
+
const autoMerge = enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true);
|
|
263
|
+
summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
|
|
264
|
+
if (autoMerge.status === "failed") {
|
|
265
|
+
opts.log.write("mr-auto-merge-failed", failureLogFields(node, autoMerge));
|
|
266
|
+
blockBranchIssue(opts, node, summary, autoMerge.code ?? "mr_create_failed", `MR auto-merge failed: ${autoMerge.message ?? autoMerge.error ?? "unknown error"}`, autoMerge);
|
|
267
|
+
summaries.push(summary);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
opts.log.write("mr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
|
|
271
|
+
}
|
|
215
272
|
}
|
|
216
|
-
else if (
|
|
273
|
+
else if (createsReview) {
|
|
217
274
|
summary.pushStatus = "skipped";
|
|
218
275
|
summary.pr = { status: "skipped", error: commit ? undefined : "no_changes" };
|
|
219
276
|
}
|
|
277
|
+
else if (commit && completionMode === "direct-merge") {
|
|
278
|
+
if (!opts.keepWorktrees)
|
|
279
|
+
removeTicketWorktree(opts.projectDir, worktreePath);
|
|
280
|
+
const mergeCommit = squashMergeBranchToLocalBase(opts.projectDir, node.branch, node.baseBranch, `${node.ticket.id}: ${node.ticket.title}`);
|
|
281
|
+
summary.pr = { status: "merged", url: mergeCommit };
|
|
282
|
+
opts.log.write("branch-direct-merge", {
|
|
283
|
+
ticket: node.ticket.id,
|
|
284
|
+
branch: node.branch,
|
|
285
|
+
base: node.baseBranch,
|
|
286
|
+
commit: mergeCommit,
|
|
287
|
+
});
|
|
288
|
+
if (opts.cleanupBranches ?? true)
|
|
289
|
+
deleteLocalBranch(opts.projectDir, node.branch);
|
|
290
|
+
}
|
|
220
291
|
cmdComplete(opts.projectDir, node.ticket.id, {
|
|
221
292
|
actor: "foreman",
|
|
222
293
|
summary: status.summary ?? `Completed ${node.ticket.id}`,
|
|
@@ -233,7 +304,7 @@ export async function runBranchPlan(opts) {
|
|
|
233
304
|
status: "done",
|
|
234
305
|
detail: summary.detail,
|
|
235
306
|
});
|
|
236
|
-
if (!opts.keepWorktrees)
|
|
307
|
+
if (!opts.keepWorktrees && completionMode !== "direct-merge")
|
|
237
308
|
removeTicketWorktree(opts.projectDir, worktreePath);
|
|
238
309
|
}
|
|
239
310
|
catch (err) {
|
|
@@ -255,6 +326,70 @@ export async function runBranchPlan(opts) {
|
|
|
255
326
|
}
|
|
256
327
|
return summaries;
|
|
257
328
|
}
|
|
329
|
+
async function waitForAutoMergeDependencies(opts, node, provider) {
|
|
330
|
+
const dependencyNodes = node.dependencies
|
|
331
|
+
.map((dep) => opts.plan.nodes.find((candidate) => candidate.ticket.id === dep))
|
|
332
|
+
.filter((dep) => Boolean(dep));
|
|
333
|
+
if (dependencyNodes.length === 0)
|
|
334
|
+
return { ok: true };
|
|
335
|
+
const timeoutMs = opts.autoMergeTimeoutMinutes === null || opts.autoMergeTimeoutMinutes === undefined
|
|
336
|
+
? null
|
|
337
|
+
: opts.autoMergeTimeoutMinutes * 60_000;
|
|
338
|
+
const deadline = opts.autoMergeWait && timeoutMs !== null ? Date.now() + timeoutMs : null;
|
|
339
|
+
while (true) {
|
|
340
|
+
const pending = [];
|
|
341
|
+
for (const dep of dependencyNodes) {
|
|
342
|
+
const status = provider === "gitlab"
|
|
343
|
+
? checkGitLabMrMerged(opts.projectDir, dep.branch)
|
|
344
|
+
: checkGitHubPrMerged(opts.projectDir, dep.branch);
|
|
345
|
+
if (!status.ok) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
code: status.code,
|
|
349
|
+
message: `dependency ${dep.ticket.id} merge check failed: ${status.message}`,
|
|
350
|
+
repairCommands: status.repairCommands,
|
|
351
|
+
command: status.command,
|
|
352
|
+
output: status.output,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (!status.merged)
|
|
356
|
+
pending.push(`${dep.ticket.id}${status.state ? ` (${status.state})` : ""}`);
|
|
357
|
+
}
|
|
358
|
+
if (pending.length === 0)
|
|
359
|
+
return { ok: true };
|
|
360
|
+
const message = `auto-merge dependency ${pending.join(", ")} has not merged into the root base yet`;
|
|
361
|
+
if (!opts.autoMergeWait) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
code: "dependency_unavailable",
|
|
365
|
+
message: `${message}; rerun after it merges or enable auto-merge wait in ticket setup`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (deadline !== null && Date.now() >= deadline) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
code: "dependency_unavailable",
|
|
372
|
+
message: `${message}; timed out waiting for dependency merge`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
opts.log.write("branch-auto-merge-wait", {
|
|
376
|
+
ticket: node.ticket.id,
|
|
377
|
+
pending,
|
|
378
|
+
timeoutMinutes: opts.autoMergeTimeoutMinutes ?? null,
|
|
379
|
+
});
|
|
380
|
+
await sleep(autoMergePollMs());
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function autoMergePollMs() {
|
|
384
|
+
const raw = process.env.RAFI_AUTO_MERGE_POLL_MS;
|
|
385
|
+
if (!raw)
|
|
386
|
+
return 30_000;
|
|
387
|
+
const parsed = Number.parseInt(raw, 10);
|
|
388
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 30_000;
|
|
389
|
+
}
|
|
390
|
+
function sleep(ms) {
|
|
391
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
392
|
+
}
|
|
258
393
|
function notifyIssue(enabled, message) {
|
|
259
394
|
if (enabled)
|
|
260
395
|
fireNotification("Foreman branch issue", message);
|
package/dist/branch/types.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface BranchIssue {
|
|
|
14
14
|
message: string;
|
|
15
15
|
blocking: boolean;
|
|
16
16
|
}
|
|
17
|
-
export type GitHubFailureCode = "gh_missing" | "gh_not_authenticated" | "remote_missing" | "remote_not_github" | "repo_unreachable" | "git_remote_unreachable" | "push_failed" | "pr_create_failed" | "network_or_timeout" | "unknown";
|
|
17
|
+
export type GitHubFailureCode = "gh_missing" | "glab_missing" | "gh_not_authenticated" | "glab_not_authenticated" | "remote_missing" | "remote_not_github" | "remote_not_gitlab" | "repo_unreachable" | "git_remote_unreachable" | "push_failed" | "pr_create_failed" | "mr_create_failed" | "network_or_timeout" | "unknown";
|
|
18
18
|
export interface GitHubFailure {
|
|
19
19
|
ok: false;
|
|
20
20
|
code: GitHubFailureCode;
|
|
@@ -43,13 +43,21 @@ export type GitHubOperationResult = {
|
|
|
43
43
|
ok: true;
|
|
44
44
|
output?: string;
|
|
45
45
|
} | GitHubFailure;
|
|
46
|
+
export type ReviewMergeStatus = {
|
|
47
|
+
ok: true;
|
|
48
|
+
merged: boolean;
|
|
49
|
+
state?: string;
|
|
50
|
+
url?: string;
|
|
51
|
+
} | GitHubFailure;
|
|
46
52
|
export interface BranchPlan {
|
|
47
53
|
baseRef: string;
|
|
48
54
|
nodes: BranchPlanNode[];
|
|
49
55
|
issues: BranchIssue[];
|
|
50
56
|
}
|
|
57
|
+
export type CompletionMode = "none" | "pr" | "auto-merge" | "direct-merge";
|
|
58
|
+
export type ReviewProvider = "github" | "gitlab";
|
|
51
59
|
export interface PrResult {
|
|
52
|
-
status: "created" | "existing" | "failed" | "skipped";
|
|
60
|
+
status: "created" | "existing" | "failed" | "skipped" | "auto_merge_enabled" | "merged";
|
|
53
61
|
url?: string;
|
|
54
62
|
error?: string;
|
|
55
63
|
code?: GitHubFailureCode;
|
package/dist/cli/start.js
CHANGED
|
@@ -18,8 +18,10 @@ import { StateDb } from "../tickets/stateDb.js";
|
|
|
18
18
|
import { buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
|
|
19
19
|
import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
|
|
20
20
|
import { formatGitHubFailure, preflightGh } from "../branch/github.js";
|
|
21
|
+
import { preflightGlab } from "../branch/gitlab.js";
|
|
21
22
|
import { runBranchPlan } from "../branch/runner.js";
|
|
22
23
|
import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
|
|
24
|
+
import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
|
|
23
25
|
function fail(message) {
|
|
24
26
|
console.error(`foreman: ${message}`);
|
|
25
27
|
process.exit(1);
|
|
@@ -47,6 +49,80 @@ function collectTicket(value, previous) {
|
|
|
47
49
|
function unique(values) {
|
|
48
50
|
return [...new Set(values)];
|
|
49
51
|
}
|
|
52
|
+
function optionWasProvided(command, name) {
|
|
53
|
+
const source = command.getOptionValueSource(name);
|
|
54
|
+
return source !== undefined && source !== "default";
|
|
55
|
+
}
|
|
56
|
+
function resolveStartBranchDefaults(cwd, opts, command) {
|
|
57
|
+
const setup = loadTicketSetupConfig(cwd);
|
|
58
|
+
const savedBuild = setup?.build;
|
|
59
|
+
const branchFlagProvided = optionWasProvided(command, "branchPerTicket");
|
|
60
|
+
const branchFlagOn = branchFlagProvided && opts.branchPerTicket !== false;
|
|
61
|
+
const createPrFlagProvided = optionWasProvided(command, "createPr");
|
|
62
|
+
const createPrFlagOn = createPrFlagProvided && opts.createPr !== false;
|
|
63
|
+
const completionFromFlag = typeof opts.completion === "string"
|
|
64
|
+
? parseCompletionMode(opts.completion)
|
|
65
|
+
: undefined;
|
|
66
|
+
let completionMode = completionFromFlag
|
|
67
|
+
?? (createPrFlagOn ? "pr" : undefined)
|
|
68
|
+
?? savedBuild?.completion
|
|
69
|
+
?? "none";
|
|
70
|
+
if (createPrFlagProvided && opts.createPr === false) {
|
|
71
|
+
completionMode = "none";
|
|
72
|
+
}
|
|
73
|
+
const savedBranchMode = savedBuild?.branch_strategy === "branch-per-ticket";
|
|
74
|
+
let branchMode = Boolean(savedBranchMode || branchFlagOn || createPrFlagOn || completionMode !== "none");
|
|
75
|
+
if (branchFlagProvided && opts.branchPerTicket === false) {
|
|
76
|
+
branchMode = false;
|
|
77
|
+
completionMode = "none";
|
|
78
|
+
}
|
|
79
|
+
if (completionMode !== "none")
|
|
80
|
+
branchMode = true;
|
|
81
|
+
const providerFromFlag = typeof opts.provider === "string" ? parseReviewProviderOption(opts.provider, cwd) : undefined;
|
|
82
|
+
const provider = providerFromFlag ?? (createPrFlagOn ? "github" : providerFromSaved(savedBuild?.provider, cwd));
|
|
83
|
+
const autoMergeWaitProvided = optionWasProvided(command, "autoMergeWait");
|
|
84
|
+
const autoMergeTimeoutMinutes = typeof opts.autoMergeTimeoutMinutes === "string"
|
|
85
|
+
? parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes")
|
|
86
|
+
: savedBuild?.auto_merge_timeout_minutes ?? null;
|
|
87
|
+
return {
|
|
88
|
+
branchMode,
|
|
89
|
+
completionMode,
|
|
90
|
+
reviewProvider: provider,
|
|
91
|
+
createReview: completionMode === "pr" || completionMode === "auto-merge",
|
|
92
|
+
prReady: Boolean(opts.prReady || savedBuild?.pr_ready || completionMode === "auto-merge"),
|
|
93
|
+
cleanupBranches: opts.keepWorktrees ? false : savedBuild?.cleanup ?? true,
|
|
94
|
+
rootBaseBranches: completionMode === "auto-merge" || completionMode === "direct-merge",
|
|
95
|
+
autoMergeWait: autoMergeWaitProvided ? opts.autoMergeWait !== false : savedBuild?.auto_merge_wait ?? false,
|
|
96
|
+
autoMergeTimeoutMinutes,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function parseCompletionMode(value) {
|
|
100
|
+
if (["pr", "auto-merge", "direct-merge", "none"].includes(value))
|
|
101
|
+
return value;
|
|
102
|
+
fail("--completion must be one of: pr, auto-merge, direct-merge, none");
|
|
103
|
+
}
|
|
104
|
+
function parseReviewProviderOption(value, cwd) {
|
|
105
|
+
if (value === "auto")
|
|
106
|
+
return providerFromSaved("auto", cwd);
|
|
107
|
+
if (value === "github" || value === "gitlab")
|
|
108
|
+
return value;
|
|
109
|
+
fail("--provider must be one of: auto, github, gitlab");
|
|
110
|
+
}
|
|
111
|
+
function parseOptionalPositiveInteger(value, label) {
|
|
112
|
+
if (!value.trim())
|
|
113
|
+
return null;
|
|
114
|
+
const parsed = Number.parseInt(value, 10);
|
|
115
|
+
if (Number.isInteger(parsed) && parsed > 0)
|
|
116
|
+
return parsed;
|
|
117
|
+
fail(`${label} must be a positive integer or blank`);
|
|
118
|
+
}
|
|
119
|
+
function providerFromSaved(value, cwd) {
|
|
120
|
+
if (value === "github" || value === "gitlab")
|
|
121
|
+
return value;
|
|
122
|
+
if (value === "local")
|
|
123
|
+
return undefined;
|
|
124
|
+
return detectGitProvider(cwd);
|
|
125
|
+
}
|
|
50
126
|
function branchContinueTicketHelp(cwd, flag = "--continue") {
|
|
51
127
|
const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
|
|
52
128
|
if (sessions.length === 0) {
|
|
@@ -93,6 +169,40 @@ async function ensureGitHubReadyForCreatePr(cwd, log, yes) {
|
|
|
93
169
|
}
|
|
94
170
|
}
|
|
95
171
|
}
|
|
172
|
+
async function ensureReviewProviderReady(cwd, provider, log, yes) {
|
|
173
|
+
if (provider === "github") {
|
|
174
|
+
await ensureGitHubReadyForCreatePr(cwd, log, yes);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
while (true) {
|
|
178
|
+
const result = preflightGlab(cwd);
|
|
179
|
+
if (result.ok)
|
|
180
|
+
return;
|
|
181
|
+
log.write("gitlab-readiness-failed", {
|
|
182
|
+
code: result.code,
|
|
183
|
+
message: result.message,
|
|
184
|
+
repairCommands: result.repairCommands,
|
|
185
|
+
command: result.command,
|
|
186
|
+
output: result.output,
|
|
187
|
+
});
|
|
188
|
+
const detail = formatGitHubFailure(result);
|
|
189
|
+
if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
190
|
+
fail(`GitLab MR setup failed before building:\n${detail}`);
|
|
191
|
+
}
|
|
192
|
+
console.error(`foreman: GitLab MR setup failed before building:\n${detail}\n`);
|
|
193
|
+
const action = await select({
|
|
194
|
+
message: "Retry GitLab readiness check?",
|
|
195
|
+
options: [
|
|
196
|
+
{ value: "retry", label: "Retry" },
|
|
197
|
+
{ value: "cancel", label: "Cancel" },
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
if (isCancel(action) || action === "cancel") {
|
|
201
|
+
console.log("ai-foreman: cancelled");
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
96
206
|
export function buildStartCommand() {
|
|
97
207
|
return new Command("start")
|
|
98
208
|
.description("Enlist a builder and drive it through a batch of N steps.")
|
|
@@ -108,14 +218,21 @@ export function buildStartCommand() {
|
|
|
108
218
|
.option("--fast", "fast mode — lower latency (maps to effort=low for codex)")
|
|
109
219
|
.option("--no-qa", "disable per-ticket QA review (enabled by default)")
|
|
110
220
|
.option("--branch-per-ticket", "run each selected structured ticket in an isolated git worktree and branch")
|
|
221
|
+
.option("--no-branch-per-ticket", "disable saved branch-per-ticket defaults for this run")
|
|
111
222
|
.option("--create-pr", "push each successful ticket branch and create a GitHub PR (implies --branch-per-ticket)")
|
|
223
|
+
.option("--no-create-pr", "disable saved PR/MR creation defaults for this run")
|
|
224
|
+
.option("--completion <mode>", "ticket branch completion behavior (pr | auto-merge | direct-merge | none)")
|
|
225
|
+
.option("--provider <provider>", "PR/MR provider for branch completion (auto | github | gitlab)")
|
|
226
|
+
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
227
|
+
.option("--no-auto-merge-wait", "do not wait for dependency PR/MRs before dependent tickets")
|
|
228
|
+
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
112
229
|
.option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
|
|
113
230
|
.option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "rafi")
|
|
114
231
|
.option("--max-branch-depth <n>", "maximum selected branch stack depth", "2")
|
|
115
232
|
.option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
|
|
116
233
|
.option("--keep-worktrees", "keep successful ticket worktrees for inspection")
|
|
117
234
|
.option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
|
|
118
|
-
.action(async (project, opts) => {
|
|
235
|
+
.action(async (project, opts, command) => {
|
|
119
236
|
const steps = Number.parseInt(opts.steps, 10);
|
|
120
237
|
if (!Number.isInteger(steps) || steps < 1) {
|
|
121
238
|
fail("--steps must be a positive integer");
|
|
@@ -127,6 +244,7 @@ export function buildStartCommand() {
|
|
|
127
244
|
const cwd = resolve(project);
|
|
128
245
|
if (!existsSync(cwd))
|
|
129
246
|
fail(`project directory not found: ${cwd}`);
|
|
247
|
+
const branchDefaults = resolveStartBranchDefaults(cwd, opts, command);
|
|
130
248
|
let agent;
|
|
131
249
|
try {
|
|
132
250
|
agent = resolveAgentForProject(cwd, opts.agent);
|
|
@@ -165,7 +283,7 @@ export function buildStartCommand() {
|
|
|
165
283
|
if (opts.resume && opts.continue) {
|
|
166
284
|
fail("choose either --resume <sessionId> or --continue, not both");
|
|
167
285
|
}
|
|
168
|
-
const branchMode =
|
|
286
|
+
const branchMode = branchDefaults.branchMode;
|
|
169
287
|
const continueTickets = opts.ticket ?? [];
|
|
170
288
|
const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
|
|
171
289
|
if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
|
|
@@ -188,6 +306,9 @@ export function buildStartCommand() {
|
|
|
188
306
|
fail("--tickets is not supported with --branch-per-ticket; initialize and use .tickets/tickets.yaml");
|
|
189
307
|
if (!isTicketsInitialized(cwd))
|
|
190
308
|
fail("--branch-per-ticket requires initialized .tickets/ (run ai-foreman tickets init)");
|
|
309
|
+
if (branchDefaults.createReview && !branchDefaults.reviewProvider) {
|
|
310
|
+
fail("PR/MR completion is enabled but no GitHub or GitLab origin remote was detected; pass --provider github|gitlab or --completion none");
|
|
311
|
+
}
|
|
191
312
|
}
|
|
192
313
|
const resumeSessionId = opts.resume ??
|
|
193
314
|
(!branchMode && opts.continue ? findLastSessionId(join(cwd, ".foreman")) : undefined);
|
|
@@ -225,8 +346,8 @@ export function buildStartCommand() {
|
|
|
225
346
|
catch (err) {
|
|
226
347
|
fail(err instanceof Error ? err.message : String(err));
|
|
227
348
|
}
|
|
228
|
-
if (
|
|
229
|
-
await
|
|
349
|
+
if (branchDefaults.createReview && branchDefaults.reviewProvider) {
|
|
350
|
+
await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
|
|
230
351
|
}
|
|
231
352
|
const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
|
|
232
353
|
label: "start",
|
|
@@ -290,6 +411,7 @@ export function buildStartCommand() {
|
|
|
290
411
|
baseRef: opts.base ?? currentGitRef(cwd),
|
|
291
412
|
branchPrefix: opts.branchPrefix,
|
|
292
413
|
maxBranchDepth,
|
|
414
|
+
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
293
415
|
});
|
|
294
416
|
let auditBuilder;
|
|
295
417
|
let auditViewer;
|
|
@@ -304,6 +426,7 @@ export function buildStartCommand() {
|
|
|
304
426
|
branchPrefix: opts.branchPrefix,
|
|
305
427
|
maxBranchDepth,
|
|
306
428
|
auditDependencies,
|
|
429
|
+
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
307
430
|
});
|
|
308
431
|
log.write("branch-plan", {
|
|
309
432
|
baseRef: plan.baseRef,
|
|
@@ -353,9 +476,14 @@ export function buildStartCommand() {
|
|
|
353
476
|
fast: opts.fast,
|
|
354
477
|
notificationsEnabled: config.notifications.enabled,
|
|
355
478
|
qaEnabled,
|
|
356
|
-
createPr:
|
|
357
|
-
|
|
479
|
+
createPr: branchDefaults.createReview,
|
|
480
|
+
completionMode: branchDefaults.completionMode,
|
|
481
|
+
reviewProvider: branchDefaults.reviewProvider,
|
|
482
|
+
prReady: branchDefaults.prReady,
|
|
358
483
|
keepWorktrees: Boolean(opts.keepWorktrees),
|
|
484
|
+
cleanupBranches: branchDefaults.cleanupBranches,
|
|
485
|
+
autoMergeWait: branchDefaults.autoMergeWait,
|
|
486
|
+
autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
|
|
359
487
|
allowedBaseDirtyPaths,
|
|
360
488
|
trackerPaths: {
|
|
361
489
|
progressDoc: ticketsConfig.paths.progressDoc,
|
|
@@ -366,7 +494,7 @@ export function buildStartCommand() {
|
|
|
366
494
|
observeBuilder: (builder) => printEvents(builder.events()),
|
|
367
495
|
});
|
|
368
496
|
console.log("foreman: branch run summary");
|
|
369
|
-
console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\
|
|
497
|
+
console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
|
|
370
498
|
for (const row of summaries) {
|
|
371
499
|
console.log([
|
|
372
500
|
row.ticket,
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -14,4 +14,15 @@ export declare function buildPopulateAgentRunOptions(opts: {
|
|
|
14
14
|
yes?: boolean;
|
|
15
15
|
log: Log;
|
|
16
16
|
}): RoleBuilderOptions;
|
|
17
|
+
interface PopulateCommandOptions {
|
|
18
|
+
project?: string;
|
|
19
|
+
agent?: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
effort?: string;
|
|
22
|
+
sources?: string[];
|
|
23
|
+
fast?: boolean;
|
|
24
|
+
yes?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
|
|
17
27
|
export declare function buildTicketsCommand(): Command;
|
|
28
|
+
export {};
|