Skip to content
Merged
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
2 changes: 1 addition & 1 deletion web/app/flows/onboarding/RunOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function RunOptions({ draft, chosen, setDestination, onTrack, getJourneyI
<li><strong>Check and run on a new branch</strong>{command(LOCAL_RUN, 'run commands')}</li>
</ol>
<details className={s.localRequirements} onToggle={event => onTrack('help_toggled', { section: 'local_requirements', open: event.currentTarget.open })}><summary>Requirements and local behavior</summary>
<p>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 <code>checkCommand</code> in the flow.</p>
<p>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 <code>checkCommand</code> in the flow.</p>
<p>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.</p>
<p>In GitHub, require approving reviews and passing CI checks in your target branch’s rules. A repository administrator needs to configure these protections.</p>
<p>This runs one ticket. Automatic triggers from your issue tracker require a separate connection.</p>
Expand Down
4 changes: 2 additions & 2 deletions web/app/flows/onboarding/SourcePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

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';
import s from './onboarding.module.css';

export function SourceIcon({ id }: { id: IssueSourceId }) {
if (id === 'slack') return <span className={s.sourceIcon}><img src="/integration-logos/slack.svg" alt="" className={s.slackLogo} width={35} height={35} /></span>;
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 <Icon className={`${s.sourceIcon} ${s[`source_${id}`]}`} aria-hidden="true" />;
}

Expand Down
6 changes: 3 additions & 3 deletions web/app/flows/onboarding/WorkflowPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,7 +103,7 @@ export function WorkflowPlan({ draft, onChange, onTrack }: { draft: FactoryDraft
<Node className={`${s.processNode} ${human ? s.processGate : ''} ${agents.length ? s.processEditable : ''}`} {...(agents.length ? { type: 'button' as const, 'aria-label': `Edit ${step} agent settings`, onClick: () => { setEditing(roles); onTrack('agent_settings_opened', { role: roles[0] }); } } : {})}>
<span className={`${s.processAvatars} ${agents.length > 1 ? s.processAgentCluster : ''}`}>
{agents.map((id, agentIndex) => <ProcessAgent key={agentIndex} id={id} />)}
{script && <span className={s.processPerson}>{step === 'Open PR' ? <SiGithub size={21} aria-hidden="true" /> : <Terminal size={21} aria-hidden="true" />}</span>}
{script && <span className={s.processPerson}>{step === 'Open PR' ? (repositoryHost(draft.sources) === 'gitlab' ? <SiGitlab size={21} aria-hidden="true" /> : <SiGithub size={21} aria-hidden="true" />) : <Terminal size={21} aria-hidden="true" />}</span>}
{human && <span className={s.processPerson}><UserRound size={21} aria-hidden="true" /></span>}
</span>
<span className={s.processText}>
Expand Down
1 change: 1 addition & 0 deletions web/app/flows/onboarding/onboarding.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
32 changes: 29 additions & 3 deletions web/lib/flow-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Comment thread
kjgbot marked this conversation as resolved.

function repoProblem(target) {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) return "There is no " + target;
Expand All @@ -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 <url>";
}
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.";
Expand Down Expand Up @@ -304,14 +326,18 @@ try {
await relocate();
}

let originUrl = "";
Comment thread
kjgbot marked this conversation as resolved.
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 <url>");
}

// 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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions web/lib/flow-preview.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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' } : {}),
};
}),
Expand Down
16 changes: 16 additions & 0 deletions web/lib/flow-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -38,6 +44,16 @@ export type SourceSettings = Partial<Record<SourceFilterKey, string>> & { mentio
export type SourcePreferences = Partial<Record<IssueSourceId, SourceSettings>>;
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]) => {
Expand Down
17 changes: 16 additions & 1 deletion web/lib/flow-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -520,7 +532,10 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType<typeof wor
// draft, with the verdict, the script and the output in its body.
const checkReport = ${JSON.stringify(FLOW_CHECK_REPORT_COMMAND)};
await f.run("check=" + check + "; baseline=" + verdictOf(baseline) + "; " + checkReport);
await f.run('gh pr create --title "Software factory change" --body-file .relayflow/pr-body.md' + (broken(check) ? " --draft" : ""));
// Hosted runs put relayflow-open-change on PATH: gh pr create on GitHub, a
// merge request on GitLab. A local run has only gh.
const openChange = ${JSON.stringify(FLOW_OPEN_CHANGE_COMMAND)};
await f.run(openChange + ' --title "Software factory change" --body-file .relayflow/pr-body.md' + (broken(check) ? " --draft" : ""));
if (broken(check) && (baseline === "pass" || baseline === "new")) {
// The base commit passes and this branch does not, or the checks are the
// change's own and fail: the change broke them and repair could not fix
Expand Down
77 changes: 77 additions & 0 deletions web/lib/test/flow-gitlab-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import ts from 'typescript';
import { DEFAULT_FACTORY, factorySource, type FactoryDraft } from '../flow-onboarding';
import { flowPreview } from '../flow-preview';
import { localInput } from '../flow-local';
import { ISSUE_SOURCES, issueSourceCode, repositoryHost, validSourcePreferences } from '../flow-sources';

/**
* GitLab as a ticket source and, without a GitHub source, as the deploy target.
* The shapes follow AgentWorkforce/cloud#3800/#3801: the deploy handoff reads
* `sourceSettings.gitlab.project` ("namespace/project") and `labels`, and a
* delivered GitLab issue reaches the flow as
* `{ source: "gitlab", title, body, labels, project }`.
*/
const gitlab: FactoryDraft = {
...DEFAULT_FACTORY, sources: ['gitlab'], agents: ['claude'], workflow: 'simple', step: 3,
sourceSettings: { gitlab: { project: 'AgentWorkforce-group/AgentWorkforce-project', labels: 'garden-ready' } },
};

/** Every export of the generated local flow, with `flow()` returning the body. */
function load(source: string) {
const exports: Record<string, unknown> = {};
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]+)+$/);
});
});
Loading
Loading