Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function createProgram(): Command {
.option("--base-branch <branch>", "Base branch for PRs")
.option("--create-pr", "Create pull request after each task")
.option("--draft-pr", "Create PRs as draft")
.option("--glab", "Use GitLab MR instead of GitHub PR (with --create-pr)")
.option("--prd <path>", "PRD file or folder (auto-detected)", "PRD.md")
.option("--yaml <file>", "YAML task file")
.option("--json <file>", "JSON task file")
Expand Down Expand Up @@ -143,8 +144,9 @@ export function parseArgs(args: string[]): {
verbose: opts.verbose || false,
branchPerTask: opts.branchPerTask || false,
baseBranch: opts.baseBranch || "",
createPr: opts.createPr || false,
createPr: opts.createPr || opts.glab || false,
draftPr: opts.draftPr || false,
createMr: opts.glab || false,
parallel: opts.parallel || false,
maxParallel: Number.parseInt(opts.maxParallel, 10) || 3,
prdSource,
Expand Down
7 changes: 6 additions & 1 deletion cli/src/cli/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ export async function runLoop(options: RuntimeOptions): Promise<void> {

// Get base branch if needed
let baseBranch = options.baseBranch;
if ((options.branchPerTask || options.parallel || options.createPr) && !baseBranch) {
if (
(options.branchPerTask || options.parallel || options.createPr || options.createMr) &&
!baseBranch
) {
baseBranch = await getDefaultBaseBranch(workDir);

// Check if base branch is empty (unborn branch - no commits yet)
Expand Down Expand Up @@ -127,6 +130,7 @@ export async function runLoop(options: RuntimeOptions): Promise<void> {
baseBranch,
createPr: options.createPr,
draftPr: options.draftPr,
createMr: options.createMr,
autoCommit: options.autoCommit,
browserEnabled: options.browserEnabled,
maxParallel: options.maxParallel,
Expand Down Expand Up @@ -155,6 +159,7 @@ export async function runLoop(options: RuntimeOptions): Promise<void> {
baseBranch,
createPr: options.createPr,
draftPr: options.draftPr,
createMr: options.createMr,
autoCommit: options.autoCommit,
browserEnabled: options.browserEnabled,
activeSettings,
Expand Down
3 changes: 3 additions & 0 deletions cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export interface RuntimeOptions {
createPr: boolean;
/** Create draft PR */
draftPr: boolean;
/** Create GitLab MR instead of GitHub PR */
createMr: boolean;
/** Run tasks in parallel */
parallel: boolean;
/** Maximum parallel agents */
Expand Down Expand Up @@ -133,6 +135,7 @@ export const DEFAULT_OPTIONS: RuntimeOptions = {
baseBranch: "",
createPr: false,
draftPr: false,
createMr: false,
parallel: false,
maxParallel: 3,
prdSource: "markdown",
Expand Down
42 changes: 31 additions & 11 deletions cli/src/execution/sequential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logTaskProgress } from "../config/writer.ts";
import type { AIEngine, AIResult } from "../engines/types.ts";
import { createTaskBranch, returnToBaseBranch } from "../git/branch.ts";
import { syncPrdToIssue } from "../git/issue-sync.ts";
import { createMergeRequest } from "../git/mr.ts";
import { createPullRequest } from "../git/pr.ts";
import type { Task, TaskSource } from "../tasks/types.ts";
import { logDebug, logError, logInfo, logSuccess, logWarn } from "../ui/logger.ts";
Expand All @@ -25,6 +26,7 @@ export interface ExecutionOptions {
baseBranch: string;
createPr: boolean;
draftPr: boolean;
createMr: boolean;
autoCommit: boolean;
browserEnabled: "auto" | "true" | "false";
prdFile?: string;
Expand Down Expand Up @@ -67,6 +69,7 @@ export async function runSequential(options: ExecutionOptions): Promise<Executio
baseBranch,
createPr,
draftPr,
createMr,
autoCommit,
browserEnabled,
activeSettings,
Expand Down Expand Up @@ -188,19 +191,36 @@ export async function runSequential(options: ExecutionOptions): Promise<Executio
notifyTaskComplete(task.title);
clearDeferredTask(taskSource.type, task, workDir, options.prdFile);

// Create PR if needed
// Create PR/MR if needed
if (createPr && branch && baseBranch) {
const prUrl = await createPullRequest(
branch,
baseBranch,
task.title,
`Automated PR created by Ralphy\n\n${aiResult.response}`,
draftPr,
workDir,
);
const description = `Automated ${createMr ? "MR" : "PR"} created by Ralphy\n\n${aiResult.response}`;

if (prUrl) {
logSuccess(`PR created: ${prUrl}`);
if (createMr) {
const mrUrl = await createMergeRequest(
branch,
baseBranch,
task.title,
description,
draftPr,
workDir,
);

if (mrUrl) {
logSuccess(`MR created: ${mrUrl}`);
}
} else {
const prUrl = await createPullRequest(
branch,
baseBranch,
task.title,
description,
draftPr,
workDir,
);

if (prUrl) {
logSuccess(`PR created: ${prUrl}`);
}
}
Comment on lines 195 to 224
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent failure when MR creation fails

When createMergeRequest returns null (e.g. push failure, glab not installed, auth failure), there is no error or warning logged — the code just silently continues. This makes debugging difficult for users. Consider adding an else branch to log a warning:

if (mrUrl) {
    logSuccess(`MR created: ${mrUrl}`);
} else {
    logWarn("Failed to create GitLab MR. Ensure 'glab' CLI is installed and authenticated.");
}

The same issue exists in the existing PR path (prUrl null case), but given this is new code, it's worth addressing here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/execution/sequential.ts
Line: 195:224

Comment:
**Silent failure when MR creation fails**

When `createMergeRequest` returns `null` (e.g. push failure, `glab` not installed, auth failure), there is no error or warning logged — the code just silently continues. This makes debugging difficult for users. Consider adding an else branch to log a warning:

```
if (mrUrl) {
    logSuccess(`MR created: ${mrUrl}`);
} else {
    logWarn("Failed to create GitLab MR. Ensure 'glab' CLI is installed and authenticated.");
}
```

The same issue exists in the existing PR path (`prUrl` null case), but given this is new code, it's worth addressing here.

How can I resolve this? If you propose a fix, please make it concise.

}
} else {
Expand Down
1 change: 1 addition & 0 deletions cli/src/git/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./branch.ts";
export * from "./worktree.ts";
export * from "./pr.ts";
export * from "./mr.ts";
export * from "./merge.ts";
80 changes: 80 additions & 0 deletions cli/src/git/mr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { parseArgs } from "../cli/args.ts";

describe("--glab flag parsing", () => {
it("should set createMr to true when --glab is passed", () => {
const { options } = parseArgs(["node", "ralphy", "--glab"]);

expect(options.createMr).toBe(true);
});

it("should imply createPr when --glab is passed", () => {
const { options } = parseArgs(["node", "ralphy", "--glab"]);

expect(options.createPr).toBe(true);
});

it("should not set createMr when --glab is not passed", () => {
const { options } = parseArgs(["node", "ralphy"]);

expect(options.createMr).toBe(false);
});

it("should keep createPr false when neither --create-pr nor --glab is passed", () => {
const { options } = parseArgs(["node", "ralphy"]);

expect(options.createPr).toBe(false);
});

it("should allow --glab with --draft-pr", () => {
const { options } = parseArgs(["node", "ralphy", "--glab", "--draft-pr"]);

expect(options.createMr).toBe(true);
expect(options.createPr).toBe(true);
expect(options.draftPr).toBe(true);
});

it("should allow --glab with --base-branch", () => {
const { options } = parseArgs(["node", "ralphy", "--glab", "--base-branch", "develop"]);

expect(options.createMr).toBe(true);
expect(options.baseBranch).toBe("develop");
});

it("should allow --glab with --branch-per-task", () => {
const { options } = parseArgs(["node", "ralphy", "--glab", "--branch-per-task"]);

expect(options.createMr).toBe(true);
expect(options.branchPerTask).toBe(true);
});
});

describe("settings display with --glab", () => {
it("should show mr setting when createMr is true", async () => {
const { buildActiveSettings } = await import("../ui/settings.ts");
const { DEFAULT_OPTIONS } = await import("../config/types.ts");

const settings = buildActiveSettings({
...DEFAULT_OPTIONS,
createPr: true,
createMr: true,
});

expect(settings).toContain("mr");
expect(settings).not.toContain("pr");
});

it("should show pr setting when createPr is true but createMr is false", async () => {
const { buildActiveSettings } = await import("../ui/settings.ts");
const { DEFAULT_OPTIONS } = await import("../config/types.ts");

const settings = buildActiveSettings({
...DEFAULT_OPTIONS,
createPr: true,
createMr: false,
});

expect(settings).toContain("pr");
expect(settings).not.toContain("mr");
});
});
75 changes: 75 additions & 0 deletions cli/src/git/mr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import simpleGit, { type SimpleGit } from "simple-git";
import { execCommand } from "../engines/base.ts";

/**
* Push a branch to origin for GitLab
*/
export async function pushBranchGlab(branch: string, workDir = process.cwd()): Promise<boolean> {
const git: SimpleGit = simpleGit(workDir);

try {
await git.push("origin", branch, ["--set-upstream"]);
return true;
} catch {
return false;
}
}

/**
* Create a merge request using glab CLI
*/
export async function createMergeRequest(
branch: string,
baseBranch: string,
title: string,
body: string,
draft = false,
workDir = process.cwd(),
): Promise<string | null> {
// Push branch first
const pushed = await pushBranchGlab(branch, workDir);
if (!pushed) {
return null;
}

// Build glab mr create command args
const args = [
"mr",
"create",
"--target-branch",
baseBranch,
"--source-branch",
branch,
"--title",
title,
"--description",
body,
"--no-editor",
];

if (draft) {
args.push("--draft");
}

// Execute glab CLI
const { stdout, exitCode } = await execCommand("glab", args, workDir);

if (exitCode !== 0) {
return null;
}

// Return the MR URL (glab outputs the URL on success)
return stdout.trim() || null;
Comment on lines +55 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

glab output includes more than a URL

glab mr create outputs multi-line text (e.g. "Creating merge request for branch into main in group/project\n\nhttps://gitlab.com/...") whereas gh pr create outputs only the URL. Using stdout.trim() here will return the entire multi-line output, not just the MR URL. This will result in messy log output and an incorrect return value if it's ever consumed as a URL.

Consider extracting the URL from the output:

Suggested change
const { stdout, exitCode } = await execCommand("glab", args, workDir);
if (exitCode !== 0) {
return null;
}
// Return the MR URL (glab outputs the URL on success)
return stdout.trim() || null;
const { stdout, exitCode } = await execCommand("glab", args, workDir);
if (exitCode !== 0) {
return null;
}
// glab outputs multi-line text; extract the URL (last line or first https:// match)
const urlMatch = stdout.match(/https?:\/\/\S+/);
return urlMatch ? urlMatch[0].trim() : stdout.trim() || null;
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/git/mr.ts
Line: 55:62

Comment:
**`glab` output includes more than a URL**

`glab mr create` outputs multi-line text (e.g. `"Creating merge request for branch into main in group/project\n\nhttps://gitlab.com/..."`) whereas `gh pr create` outputs only the URL. Using `stdout.trim()` here will return the entire multi-line output, not just the MR URL. This will result in messy log output and an incorrect return value if it's ever consumed as a URL.

Consider extracting the URL from the output:

```suggestion
	const { stdout, exitCode } = await execCommand("glab", args, workDir);

	if (exitCode !== 0) {
		return null;
	}

	// glab outputs multi-line text; extract the URL (last line or first https:// match)
	const urlMatch = stdout.match(/https?:\/\/\S+/);
	return urlMatch ? urlMatch[0].trim() : stdout.trim() || null;
```

How can I resolve this? If you propose a fix, please make it concise.

}

/**
* Check if glab CLI is available and authenticated
*/
export async function isGlabAvailable(): Promise<boolean> {
try {
const { exitCode } = await execCommand("glab", ["auth", "status"], process.cwd());
return exitCode === 0;
} catch {
return false;
}
}
Comment on lines +65 to +75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isGlabAvailable() is never called

This function is defined but never used. The --glab flag will silently fail if the glab CLI is not installed or not authenticated — createMergeRequest returns null and the user gets no feedback. Consider calling isGlabAvailable() early in the execution pipeline (similar to isEngineAvailable) to provide a clear error message before task execution begins.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/git/mr.ts
Line: 65:75

Comment:
**`isGlabAvailable()` is never called**

This function is defined but never used. The `--glab` flag will silently fail if the `glab` CLI is not installed or not authenticated — `createMergeRequest` returns `null` and the user gets no feedback. Consider calling `isGlabAvailable()` early in the execution pipeline (similar to `isEngineAvailable`) to provide a clear error message before task execution begins.

How can I resolve this? If you propose a fix, please make it concise.

3 changes: 2 additions & 1 deletion cli/src/ui/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function buildActiveSettings(options: RuntimeOptions): string[] {

if (options.dryRun) activeSettings.push("dry-run");
if (options.branchPerTask) activeSettings.push("branch");
if (options.createPr) activeSettings.push("pr");
if (options.createPr && options.createMr) activeSettings.push("mr");
else if (options.createPr) activeSettings.push("pr");
if (options.parallel) activeSettings.push("parallel");
if (!options.autoCommit) activeSettings.push("no-commit");

Expand Down