diff --git a/web/app/flows/onboarding/RunOptions.tsx b/web/app/flows/onboarding/RunOptions.tsx
index 0907d5e..d96e237 100644
--- a/web/app/flows/onboarding/RunOptions.tsx
+++ b/web/app/flows/onboarding/RunOptions.tsx
@@ -78,7 +78,7 @@ export function RunOptions({ draft, chosen, setDestination, onTrack, getJourneyI
Check and run on a new branch{command(LOCAL_RUN, 'run commands')}
onTrack('help_toggled', { section: 'local_requirements', open: event.currentTarget.open })}>Requirements and local behavior
- Node.js 22.18+, macOS (Apple silicon) or Linux (x64), your selected coding agents installed and signed in, and GitHub CLI authenticated. Start with a clean Git repository whose tests run the way its CI runs them; the flow reads your CI configuration to find the command, or you can set checkCommand in the flow.
+ Node.js 22.18+, macOS (Apple silicon) or Linux (x64), your selected coding agents installed and signed in, and GitHub CLI authenticated (GitLab repositories need the Cloud deploy: a local run opens its pull request with GitHub CLI). Start with a clean Git repository whose tests run the way its CI runs them; the flow reads your CI configuration to find the command, or you can set checkCommand in the flow.
The local version uses a two-hour runtime limit, not a dollar cap. Your coding agent’s usage charges still apply. Every preset stops at “needs_human” for you to review and merge the PR in GitHub.
In GitHub, require approving reviews and passing CI checks in your target branch’s rules. A repository administrator needs to configure these protections.
This runs one ticket. Automatic triggers from your issue tracker require a separate connection.
diff --git a/web/app/flows/onboarding/SourcePicker.tsx b/web/app/flows/onboarding/SourcePicker.tsx
index 108ec8e..4a13946 100644
--- a/web/app/flows/onboarding/SourcePicker.tsx
+++ b/web/app/flows/onboarding/SourcePicker.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { ListFilter, ChevronDown } from 'lucide-react';
-import { SiGithub, SiLinear, SiShortcut, SiJira, SiMarkdown } from 'react-icons/si';
+import { SiGithub, SiGitlab, SiLinear, SiShortcut, SiJira, SiMarkdown } from 'react-icons/si';
import { ISSUE_SOURCES, sourceLabel, type IssueSourceId, type SourceSettings } from '../../../lib/flow-sources';
import type { FactoryDraft } from '../../../lib/flow-onboarding';
import type { FlowTrack } from '../../../lib/flow-analytics';
@@ -10,7 +10,7 @@ import s from './onboarding.module.css';
export function SourceIcon({ id }: { id: IssueSourceId }) {
if (id === 'slack') return
;
- const Icon = { github: SiGithub, linear: SiLinear, shortcut: SiShortcut, jira: SiJira, markdown: SiMarkdown }[id];
+ const Icon = { github: SiGithub, gitlab: SiGitlab, linear: SiLinear, shortcut: SiShortcut, jira: SiJira, markdown: SiMarkdown }[id];
return ;
}
diff --git a/web/app/flows/onboarding/WorkflowPicker.tsx b/web/app/flows/onboarding/WorkflowPicker.tsx
index a70409d..b956199 100644
--- a/web/app/flows/onboarding/WorkflowPicker.tsx
+++ b/web/app/flows/onboarding/WorkflowPicker.tsx
@@ -3,13 +3,13 @@ import { resolveAgentSettings, rolesForStep, type AgentRole } from '../../../lib
import { GitBranch, Layers3, Zap, UserRound, LockKeyhole, Terminal, Settings } from 'lucide-react';
import { useState } from 'react';
import { WORKFLOWS, WORKFLOW_STEP_DETAILS, workflowAgents } from '../../../lib/flow-workflows';
-import { SiGithub } from 'react-icons/si';
+import { SiGithub, SiGitlab } from 'react-icons/si';
import Claude from '@lobehub/icons/es/Claude';
import Codex from '@lobehub/icons/es/Codex';
import Cursor from '@lobehub/icons/es/Cursor';
import OpenCode from '@lobehub/icons/es/OpenCode';
import { agentLabel, canContinue, isCodingAgent, type CodingAgent, type FactoryDraft } from '../../../lib/flow-onboarding';
-import { sourceLabel, sourceSummary } from '../../../lib/flow-sources';
+import { repositoryHost, sourceLabel, sourceSummary } from '../../../lib/flow-sources';
import { SourceIcon } from './SourcePicker';
import type { FlowTrack } from '../../../lib/flow-analytics';
import s from './onboarding.module.css';
@@ -103,7 +103,7 @@ export function WorkflowPlan({ draft, onChange, onTrack }: { draft: FactoryDraft
{ setEditing(roles); onTrack('agent_settings_opened', { role: roles[0] }); } } : {})}>
1 ? s.processAgentCluster : ''}`}>
{agents.map((id, agentIndex) => )}
- {script && {step === 'Open PR' ? : }}
+ {script && {step === 'Open PR' ? (repositoryHost(draft.sources) === 'gitlab' ? : ) : }}
{human && }
diff --git a/web/app/flows/onboarding/onboarding.module.css b/web/app/flows/onboarding/onboarding.module.css
index 054f9ad..cd19520 100644
--- a/web/app/flows/onboarding/onboarding.module.css
+++ b/web/app/flows/onboarding/onboarding.module.css
@@ -123,6 +123,7 @@
.sourceIcon { width: 25px; height: 25px; flex-shrink: 0; position: relative; display: inline-flex; }
.slackLogo { position: absolute; width: 140%; height: 140%; max-width: none; left: -20%; top: -20%; }
.source_github { color: #edf4fb; }
+.source_gitlab { color: #fc6d26; }
.source_linear { color: #9395ff; }
.source_shortcut { color: #ac91ff; }
.source_jira { color: #579dff; }
diff --git a/web/lib/flow-local.ts b/web/lib/flow-local.ts
index be8f4df..aff1fb8 100644
--- a/web/lib/flow-local.ts
+++ b/web/lib/flow-local.ts
@@ -195,6 +195,26 @@ function quote(path) {
// Everything the flow will need of the destination, checked before a single
// file is written: the same three conditions this script enforces here, so a
// relocated kit cannot land somewhere its own preflight would reject.
+// A local run opens its change with GitHub CLI, which cannot open a GitLab
+// merge request; Cloud runs can (they carry relayflow-open-change). Shared by
+// the main guard and repoProblem, so a relocated kit is refused the same way.
+const GITLAB_LOCAL_ADVICE = [
+ "Local runs open the pull request with GitHub CLI (gh pr create), which cannot open a GitLab merge request.",
+ "Deploy this flow to Agent Relay Cloud instead: hosted runs open the merge request for you.",
+];
+// Judged on the remote's host alone, never on its path: github.com/gitlab-org/x
+// or a repository named gitlab is a GitHub remote. This script is a template
+// literal, so the patterns use [.] and [/] rather than backslash escapes, which
+// the template would silently drop.
+function originHost(url) {
+ const match = /^[a-z][a-z0-9+.-]*:[/][/](?:[^@/]*@)?([^/:]+)/i.exec(url) || /^(?:[^@/]*@)?([^/:]+):/.exec(url);
+ return match ? match[1].toLowerCase() : "";
+}
+function isGitLabOrigin(url) {
+ const host = originHost(url);
+ return host === "gitlab.com" || host.endsWith(".gitlab.com") || host.startsWith("gitlab.");
+}
+
function repoProblem(target) {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) return "There is no " + target;
@@ -204,11 +224,13 @@ function repoProblem(target) {
} catch {
return "Not a Git repository: " + target;
}
+ let origin = "";
try {
- git("-C", target, "remote", "get-url", "origin");
+ origin = git("-C", target, "remote", "get-url", "origin").trim();
} catch {
return "No origin remote there. The flow ends in git push and gh pr create, so add one first: git remote add origin ";
}
+ if (isGitLabOrigin(origin)) return "That repository is on GitLab. " + GITLAB_LOCAL_ADVICE.join(" ");
const unclean = dirtyPaths("-C", target);
if (unclean.length) {
return "Uncommitted changes there (" + unclean.slice(0, 3).join(", ") + (unclean.length > 3 ? ", ..." : "") + "). Commit or stash them first; the flow commits and pushes a branch.";
@@ -304,14 +326,18 @@ try {
await relocate();
}
+let originUrl = "";
try {
- git("remote", "get-url", "origin");
+ originUrl = git("remote", "get-url", "origin").trim();
} catch {
fail("this repository has no origin remote.",
"The flow ends with git push --set-upstream origin HEAD and gh pr create.",
"Add one first: git remote add origin ");
}
+// Say this before the agents run rather than letting the last step fail.
+if (isGitLabOrigin(originUrl)) fail("this repository is on GitLab.", ...GITLAB_LOCAL_ADVICE);
+
// Step 4 runs "npx flows" twice straight after this, and npx resolves from the
// directory it runs in — never from wherever this script lives. So the install
// from step 2 has to have happened in this repository, and the check has to
@@ -426,7 +452,7 @@ Requirements
- Node.js 22.18+ (for native TypeScript), npm, and Git.
- macOS on Apple silicon or Linux x64 (bundled runtime platforms).
- ${names}, installed and signed in.
-- GitHub CLI (gh), signed in, and a repository with push access to origin.
+- GitHub CLI (gh), signed in, and a repository with push access to origin. A GitLab repository needs the Cloud deploy instead: local runs open the pull request with gh, which cannot open a GitLab merge request, and the preflight stops before any agent runs.
- The flow runs your repository's own checks. Before changing any code, an agent reads your CI configuration, Makefile and README and writes .relayflow/check.sh; failing that it uses your ecosystem's default (a make or just test target, npm/pnpm/Yarn/Bun, cargo, go, pytest, bundle, Maven, Gradle, dotnet or mix). To use your own command instead, set checkCommand in software-factory.flow.mts, or commit a .relayflow/check.sh. The tools your checks need must be installed.
1. Extract this kit into your repository root. Keep any existing files before replacing them. Open a terminal in that directory.
diff --git a/web/lib/flow-preview.ts b/web/lib/flow-preview.ts
index cc0be27..3e4efa3 100644
--- a/web/lib/flow-preview.ts
+++ b/web/lib/flow-preview.ts
@@ -1,7 +1,7 @@
import { CODING_AGENTS } from './flow-agents';
import { resolveAgentSettings, rolesForStep } from './flow-agent-settings';
import type { FactoryDraft } from './flow-onboarding';
-import { sourceLabel, sourceSummary } from './flow-sources';
+import { repositoryHost, sourceLabel, sourceSummary } from './flow-sources';
import { WORKFLOWS, WORKFLOW_STEP_DETAILS } from './flow-workflows';
/** Display-only snapshot of the same plan shown by WorkflowPlan. Never executed by Cloud. */
@@ -34,7 +34,7 @@ export function flowPreview(draft: FactoryDraft) {
description: WORKFLOW_STEP_DETAILS[step],
owner: human ? 'You' : !agents.length ? 'Script' : agents.length > 1 ? 'Parallel' : agents[0].label,
detail: agents.map(agent => agents.length > 1 ? `${agent.label} · ${agent.model}` : agent.model).join('\n'),
- icons: step === 'Open PR' ? ['github'] : agents.map(agent => agent.id),
+ icons: step === 'Open PR' ? [repositoryHost(draft.sources)] : agents.map(agent => agent.id),
...(review ? { badge: '2 rounds' } : {}),
};
}),
diff --git a/web/lib/flow-sources.ts b/web/lib/flow-sources.ts
index cfa40a9..1f5edaa 100644
--- a/web/lib/flow-sources.ts
+++ b/web/lib/flow-sources.ts
@@ -3,6 +3,12 @@ export const ISSUE_SOURCES = [
{ key: 'repository', label: 'Repository', placeholder: 'owner/repository' },
{ key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent, bug' },
] },
+ { id: 'gitlab', label: 'GitLab', fields: [
+ // The project's full path, which Cloud also uses as the GitLab deploy
+ // target when no GitHub source is chosen (AgentWorkforce/cloud#3801).
+ { key: 'project', label: 'Project', placeholder: 'group/project' },
+ { key: 'labels', label: 'Required labels', placeholder: 'ready-for-agent, bug' },
+ ] },
{ id: 'linear', label: 'Linear', fields: [
{ key: 'team', label: 'Team', placeholder: 'Engineering' },
{ key: 'project', label: 'Project', placeholder: 'Website' },
@@ -38,6 +44,16 @@ export type SourceSettings = Partial> & { mentio
export type SourcePreferences = Partial>;
export const sourceLabel = (id: IssueSourceId) => ISSUE_SOURCES.find(source => source.id === id)!.label;
+/**
+ * Where the flow's change request opens. Mirrors Cloud's deploy-page inference
+ * (flow-handoff `suggestedRepository`): a GitHub source keeps GitHub; a GitLab
+ * source without one makes the GitLab project the target.
+ */
+export type RepositoryHost = 'github' | 'gitlab';
+export function repositoryHost(sources: readonly IssueSourceId[]): RepositoryHost {
+ return !sources.includes('github') && sources.includes('gitlab') ? 'gitlab' : 'github';
+}
+
export function validSourcePreferences(value: unknown): value is SourcePreferences {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
return Object.entries(value).every(([id, settings]) => {
diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts
index 321f443..1bcba7b 100644
--- a/web/lib/flow-workflows.ts
+++ b/web/lib/flow-workflows.ts
@@ -313,6 +313,18 @@ export const FLOW_DROP_WORKING_FILES_COMMAND = [
'echo done',
].join('; ');
+/**
+ * Opens the change request, whichever host the repository lives on. Cloud puts
+ * `relayflow-open-change` on PATH for every repository run
+ * (AgentWorkforce/cloud#3801): on GitHub it is `gh pr create` with the same
+ * arguments and exit status, on GitLab it opens a merge request and prints its
+ * URL. A local run has no such helper and keeps `gh pr create`. The caller
+ * appends the arguments (`--title`, `--body-file`, `--draft`), which both
+ * accept.
+ */
+export const FLOW_OPEN_CHANGE_COMMAND =
+ 'open_change() { if command -v relayflow-open-change >/dev/null 2>&1; then relayflow-open-change "$@"; else gh pr create "$@"; fi; }; open_change';
+
/**
* Decides whether there is anything to publish, before the branch is pushed and
* before `gh pr create` runs.
@@ -520,7 +532,10 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType = {};
+ const compiled = ts.transpileModule(source.replace('import { flow } from "@relayflows/surface";', ''),
+ { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } });
+ new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _header: unknown, fn: unknown) => fn);
+ return exports as { issueRejection: (issue: unknown) => string };
+}
+
+describe('GitLab ticket source', () => {
+ it('is offered with a project path and required labels, like GitHub', () => {
+ const source = ISSUE_SOURCES.find(item => item.id === 'gitlab')!;
+ expect(source.label).toBe('GitLab');
+ expect(source.fields.map(field => field.key)).toEqual(['project', 'labels']);
+ });
+
+ it('keeps GitLab settings through storage validation and refuses fields it does not have', () => {
+ expect(validSourcePreferences({ gitlab: { project: 'group/project', labels: 'ready' } })).toBe(true);
+ expect(validSourcePreferences({ gitlab: { repository: 'group/project' } })).toBe(false);
+ expect(validSourcePreferences({ gitlab: { mentioned: true } })).toBe(false);
+ });
+
+ it('makes GitLab the repository host only when no GitHub source is chosen, as Cloud infers it', () => {
+ expect(repositoryHost(['gitlab'])).toBe('gitlab');
+ expect(repositoryHost(['gitlab', 'linear'])).toBe('gitlab');
+ expect(repositoryHost(['github', 'gitlab'])).toBe('github');
+ expect(repositoryHost(['linear'])).toBe('github');
+ expect(repositoryHost([])).toBe('github');
+ });
+
+ it('declares the project field Cloud delivers on a GitLab issue', () => {
+ expect(issueSourceCode(['gitlab'], gitlab.sourceSettings)).toContain('project?: string;');
+ });
+
+ it('filters a local GitLab ticket by project (case-insensitively) and labels', () => {
+ const { issueRejection } = load(factorySource(gitlab, 'local'));
+ const ticket = { source: 'gitlab', title: 't', body: 'b', labels: ['garden-ready'], project: 'agentworkforce-group/agentworkforce-project' };
+ expect(issueRejection(ticket)).toBe('');
+ expect(issueRejection({ ...ticket, project: 'other-group/app' })).toContain('project is "other-group/app"');
+ expect(issueRejection({ ...ticket, labels: [] })).toContain('missing required label: garden-ready');
+ // The kit's own prefilled ticket passes its own filters.
+ expect(issueRejection(localInput(gitlab).issue!)).toBe('');
+ });
+
+ it('shows the GitLab mark on the change-request step when GitLab is the host', () => {
+ const openStep = (draft: FactoryDraft) => flowPreview(draft)?.nodes.find(node => 'icons' in node && node.title === 'Open PR') as { icons: string[] } | undefined;
+ expect(openStep(gitlab)?.icons).toEqual(['gitlab']);
+ expect(openStep({ ...gitlab, sources: ['github', 'gitlab'] })?.icons).toEqual(['github']);
+ });
+
+ it('hands Cloud the settings its deploy page reads to pick the GitLab project', () => {
+ // cloudConnectionsHref passes sources/sourceSettings through untouched;
+ // Cloud's flow-handoff reads sourceSettings.gitlab.project from them.
+ const source = factorySource(gitlab);
+ expect(source).toContain('relayflow-open-change');
+ expect(gitlab.sourceSettings.gitlab?.project).toMatch(/^[^/\s]+(\/[^/\s]+)+$/);
+ });
+});
diff --git a/web/lib/test/flow-local.test.ts b/web/lib/test/flow-local.test.ts
index 4809bb5..ac24a9e 100644
--- a/web/lib/test/flow-local.test.ts
+++ b/web/lib/test/flow-local.test.ts
@@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url';
import ts from 'typescript';
import { DEFAULT_FACTORY, factorySource, type FactoryDraft } from '../flow-onboarding';
import { LOCAL_INSTALL, LOCAL_PREFLIGHT, LOCAL_RUN, PLACEHOLDER_BODY, PLACEHOLDER_TITLE, RELAYFLOWS_VERSION, localInput, localKitArchive, localKitFiles } from '../flow-local';
-import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_BLOCKED_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_PUBLISH_CHECK_COMMAND } from '../flow-workflows';
+import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_BLOCKED_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PUBLISH_CHECK_COMMAND } from '../flow-workflows';
/**
* What each deterministic step reports, keyed by the command itself: three
@@ -263,7 +263,7 @@ describe('local flow starter kit', () => {
done: (reason: string) => { finish = reason; },
}, localInput(draft));
} finally { console.error = original; }
- expect(commands.some(command => command.startsWith('gh pr create'))).toBe(false);
+ expect(commands.some(command => command.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false);
expect(commands.some(command => command.startsWith('git push'))).toBe(false);
expect(finish).toBe('needs_human');
expect(messages.join('\n')).toContain('no commits');
@@ -281,7 +281,7 @@ describe('local flow starter kit', () => {
}, localInput(selected));
expect(finish).toBe('needs_human');
const testIndex = commands.indexOf(FLOW_CHECK_RUN_COMMAND);
- const createIndex = commands.findIndex(command => command.startsWith('gh pr create'));
+ const createIndex = commands.findIndex(command => command.startsWith(FLOW_OPEN_CHANGE_COMMAND));
expect(testIndex).toBeGreaterThanOrEqual(0);
expect(createIndex).toBeGreaterThan(testIndex);
expect(localKitFiles(selected)['START-HERE.txt']).toContain('require a pull request, an approving review, and passing CI status checks');
@@ -301,7 +301,7 @@ describe('local flow starter kit', () => {
run: async (command: string) => { commands.push(command); return answer(command, { check: 'fail', baseline: 'pass' }); },
done: (reason: string) => { finish = reason; },
}, localInput(selected));
- const create = commands.find(command => command.startsWith('gh pr create')) ?? '';
+ const create = commands.find(command => command.startsWith(FLOW_OPEN_CHANGE_COMMAND)) ?? '';
expect(create).toContain('--draft');
expect(commands).toContain('git push --set-upstream origin HEAD');
expect(finish).toBe('step_failed');
@@ -415,6 +415,16 @@ describe('relocating a kit that was extracted outside a repository', () => {
});
}
+ it('refuses to relocate the kit into a GitLab repository its own preflight would reject', async () => {
+ const { root, download } = workspace();
+ const target = repo(root, 'gitlab-checkout');
+ git('-C', target, 'remote', 'set-url', 'origin', 'git@gitlab.com:acme-group/app.git');
+ const { code, out } = await preflight(download, root, [target]);
+ expect(out).toContain('That repository is on GitLab.');
+ expect(existsSync(join(target, LOCAL_PREFLIGHT))).toBe(false);
+ expect(code).toBe(1);
+ }, 30_000);
+
it('copies the kit into the repository the user names and prints the sequence for it', async () => {
const { root, download } = workspace();
const target = repo(root, 'checkout (1)');
@@ -546,6 +556,29 @@ describe('relocating a kit that was extracted outside a repository', () => {
expect(code).toBe(1);
}, 30_000);
+ it.each(['git@gitlab.com:acme-group/app.git', 'https://gitlab.com/acme-group/app.git', 'https://gitlab.example.com/acme/app.git'])(
+ 'stops a GitLab repository before any agent runs, pointing at the Cloud deploy (%s)', async (origin) => {
+ const { root } = workspace();
+ const target = installed(root, 'gitlab-' + origin.length, { dependency: true, ticket: true });
+ git('-C', target, 'remote', 'set-url', 'origin', origin);
+ const { code, out, err } = await preflight(target, root, [], true, signedIn(root));
+ expect(err).toContain('this repository is on GitLab.');
+ expect(err).toContain('Deploy this flow to Agent Relay Cloud instead');
+ expect(out).not.toContain('Preconditions met');
+ expect(code).toBe(1);
+ }, 30_000);
+
+ it.each(['git@github.com:gitlab-org/app.git', 'https://github.com/acme/gitlab.git', 'https://github.com/gitlab-community/app.git', 'ssh://git@github.com/acme/gitlab.com.git'])(
+ 'does not mistake a GitHub remote that mentions gitlab for GitLab (%s)', async (origin) => {
+ const { root } = workspace();
+ const target = installed(root, 'github-' + origin.length, { dependency: true, ticket: true });
+ git('-C', target, 'remote', 'set-url', 'origin', origin);
+ const { code, out, err } = await preflight(target, root, [], true, signedIn(root));
+ expect(err).not.toContain('this repository is on GitLab.');
+ expect(out).toContain('Preconditions met. Starting the flow.');
+ expect(code).toBe(0);
+ }, 30_000);
+
it('passes the same check once step 2 has run in that repository', async () => {
const { root } = workspace();
const target = installed(root, 'installed', { dependency: true, ticket: true });
diff --git a/web/lib/test/flow-onboarding.test.ts b/web/lib/test/flow-onboarding.test.ts
index 53db066..6e15a2b 100644
--- a/web/lib/test/flow-onboarding.test.ts
+++ b/web/lib/test/flow-onboarding.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import ts from 'typescript';
-import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_BLOCKED_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND } from '../flow-workflows';
+import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_BLOCKED_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND } from '../flow-workflows';
import { cloudBlockedReason, cloudConnectionsHref, DEFAULT_FACTORY, factorySource, isMarkdownOnly, MARKDOWN_ONLY_CLOUD_NOTE, readFactoryDraft, canContinue, primaryAgent, onboardingPath, accessibleOnboardingStep, type FactoryDraft } from '../flow-onboarding';
import { localInput } from '../flow-local';
@@ -316,7 +316,7 @@ describe('software factory onboarding', () => {
// nothing is a legitimate outcome: it is not a pull request, and a branch
// pushed at the base commit is not worth leaving behind either.
const { calls, finish, errors } = await runFactory([true, true], true, matchingIssue, completed, 'no-commits');
- expect(calls.some(call => call.startsWith('gh pr create'))).toBe(false);
+ expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false);
expect(calls.some(call => call.startsWith('git push'))).toBe(false);
expect(finish).toBe('needs_human');
// The reason reaches the operator, so "nothing was built" is never silent.
@@ -329,7 +329,7 @@ describe('software factory onboarding', () => {
// fail on exactly that.
const { calls, finish, errors } = await runFactory([true, true], true, matchingIssue, completed, 'no-summary');
expect(calls).toContain('git push --set-upstream origin HEAD');
- expect(calls.some(call => call.startsWith('gh pr create'))).toBe(false);
+ expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false);
expect(finish).toBe('needs_human');
expect(errors.join('\n')).toContain('summary.md');
});
@@ -340,14 +340,14 @@ describe('software factory onboarding', () => {
// the strength of output it did not understand.
const { calls, finish } = await runFactory([true, true], true, matchingIssue, completed, 'unexpected output');
expect(calls.some(call => call.startsWith('git push'))).toBe(false);
- expect(calls.some(call => call.startsWith('gh pr create'))).toBe(false);
+ expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false);
expect(finish).toBe('needs_human');
});
it('tests and pushes the branch before opening its pull request', async () => {
const { calls } = await runFactory([true]);
const push = calls.indexOf('git push --set-upstream origin HEAD');
- const create = calls.findIndex(call => call.startsWith('gh pr create'));
+ const create = calls.findIndex(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND));
expect(push).toBeGreaterThan(calls.indexOf(FLOW_CHECK_RUN_COMMAND));
expect(create).toBeGreaterThan(push);
expect(calls.indexOf('adversary-1:codex')).toBeGreaterThan(create);
@@ -383,7 +383,7 @@ describe('software factory onboarding', () => {
expect(calls.some(call => call.includes('pr merge'))).toBe(false);
expect(factorySource({ ...completed, workflow })).not.toContain('f.human(');
expect(calls).toContain(FLOW_CHECK_RUN_COMMAND);
- expect(calls.some(call => call.startsWith('gh pr create'))).toBe(true);
+ expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(true);
}
});
@@ -427,7 +427,7 @@ describe('software factory onboarding', () => {
describe('checks', () => {
const reportCall = (calls: string[]) => calls.find(call => call.startsWith('check=')) ?? '';
- const createCall = (calls: string[]) => calls.find(call => call.startsWith('gh pr create')) ?? '';
+ const createCall = (calls: string[]) => calls.find(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND)) ?? '';
it('works out how to check the repository before the change, and excludes working files first', async () => {
const { calls } = await runFactory([true, true]);
@@ -448,7 +448,7 @@ describe('software factory onboarding', () => {
expect(calls.filter(call => call.startsWith('check-repair'))).toEqual([]);
expect(calls.some(call => call.endsWith(FLOW_BASE_CHECK_COMMAND))).toBe(false);
expect(reportCall(calls)).toMatch(/^check=pass; baseline=; /);
- expect(createCall(calls)).toBe('gh pr create --title "Software factory change" --body-file .relayflow/pr-body.md');
+ expect(createCall(calls)).toBe(FLOW_OPEN_CHANGE_COMMAND + ' --title "Software factory change" --body-file .relayflow/pr-body.md');
expect(finish).toBe('needs_human');
});
diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts
index 4da0684..82fd9aa 100644
--- a/web/lib/test/flow-workflows.test.ts
+++ b/web/lib/test/flow-workflows.test.ts
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import {
FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_REPORT_COMMAND, FLOW_CHECK_RESOLVE_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_CHECK_SCRIPT,
- FLOW_DROP_WORKING_FILES_COMMAND, FLOW_EXCLUDE_WORKING_FILES_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND,
+ FLOW_DROP_WORKING_FILES_COMMAND, FLOW_EXCLUDE_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND,
} from '../flow-workflows';
/**
@@ -608,3 +608,37 @@ describe('FLOW_CHECK_REPORT_COMMAND', () => {
expect(read(root, '.relayflow/pr-body.md').startsWith('## Checks')).toBe(true);
});
});
+
+describe('FLOW_OPEN_CHANGE_COMMAND', () => {
+ /** A bin dir with fakes that record their argv, one per line, and exit with `code`. */
+ function fakes(names: string[], code = 0) {
+ const root = fixture({});
+ const bin = path.join(root, 'bin');
+ mkdirSync(bin);
+ for (const name of names) {
+ writeFileSync(path.join(bin, name), `#!/bin/sh\nprintf '%s\\n' "${name}" "$@" > "${root}/${name}.args"\nexit ${code}\n`, { mode: 0o755 });
+ }
+ return { root, env: { PATH: `${bin}:/usr/bin:/bin` } };
+ }
+ const args = ' --title "Software factory change" --body-file .relayflow/pr-body.md --draft';
+
+ it('uses the hosted helper when Cloud put it on PATH (GitHub or GitLab alike)', () => {
+ const { root, env } = fakes(['relayflow-open-change', 'gh']);
+ expect(sh(FLOW_OPEN_CHANGE_COMMAND + args, root, env).code).toBe(0);
+ expect(read(root, 'relayflow-open-change.args').trim().split('\n'))
+ .toEqual(['relayflow-open-change', '--title', 'Software factory change', '--body-file', '.relayflow/pr-body.md', '--draft']);
+ expect(read(root, 'gh.args')).toBe('');
+ });
+
+ it('falls back to gh pr create for a local run, with the same arguments', () => {
+ const { root, env } = fakes(['gh']);
+ expect(sh(FLOW_OPEN_CHANGE_COMMAND + args, root, env).code).toBe(0);
+ expect(read(root, 'gh.args').trim().split('\n'))
+ .toEqual(['gh', 'pr', 'create', '--title', 'Software factory change', '--body-file', '.relayflow/pr-body.md', '--draft']);
+ });
+
+ it('keeps the exit status, so a failed create still fails the step', () => {
+ const { root, env } = fakes(['relayflow-open-change'], 3);
+ expect(sh(FLOW_OPEN_CHANGE_COMMAND + args, root, env).code).toBe(3);
+ });
+});