diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 20f1a847..ff834127 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -537,10 +537,18 @@ flows deploy issue-triage.flow.ts \ --repo AgentWorkforce/flows \ --on github:labels=agent \ --approver khaliqgant +flows deploy issue-triage.flow.ts --repo gitlab:group/sub/project --on gitlab:labels=agent --approver khaliqgant flows deployments flows undeploy ``` +`--repo owner/name` (or `github:owner/name`) targets GitHub. Use +`gitlab:group/sub/project` or `https://gitlab.com/group/sub/project.git` for +GitLab, including nested namespaces. GitHub HTTP(S) URLs remain supported. The CLI applies Cloud's GitLab +shape check before deploying: the namespace is at most 20 segments and 255 +characters, and each segment and the project name starts with a letter or +digit and does not end in `.`, `.git` or `.atom`. + Optional flags: `--agents claude,codex`, `--name "Issue triage"`, `--draft`, `--no-connect`, `--json`, and further `--on` sources. @@ -574,7 +582,7 @@ on `f.human`, finish on the version they started with. deploy wizard: `POST /api/v1/flows/deploy` stores one self-contained authored source and creates a proactive listener whose watch rules match the chosen ticket sources. There is no webhook to register. The workspace's GitHub App -installation (or Slack, Linear, Jira or Shortcut connection) is the ingress; +installation (or GitLab, Slack, Linear, Jira or Shortcut connection) is the ingress; Cloud ingests events into the workspace's relayfile projection and the listener's rules match them there. The digest form, `flows deploy @sha256:… --to file://…`, is unchanged; the positional @@ -589,9 +597,12 @@ decides which form is meant. `team` matches the team's name or its key. A Linear `events` is `issues` (the default — `issue.create`), `assigned` — `AppUserNotification.issueAssignedToYou`, issues assigned to the connected app user, so assigning a ticket delegates it — -or `all` for both. A GitHub source without -`repository` is -scoped to `--repo`. `events` is `issues` (the default: `issues.opened` and +or `all` for both. A GitHub source without `repository` is scoped to a GitHub +`--repo` target; +a GitLab target requires an explicit `--on github:repository=owner/name`. +A GitLab source without `project` is scoped to a GitLab target. With a GitHub +target, a GitLab source remains unscoped unless you supply `project`. +`events` is `issues` (the default: `issues.opened` and `issues.labeled`) or `pull_request` — `merge_request` for `gitlab` — which wakes on a pull request being opened, receiving commits, being reopened, or being reviewed; a pull-request run checks out the pull request's own head and receives @@ -633,7 +644,8 @@ SDK; the same function reads a compiled YAML spec, where a helper step such as `tools.relayfile` mounts in the header, `f.` use in the default body (recognised exactly as helper preflight recognises it), provider triggers (`.on(github.issues())`), the `--on` sources and the deploy target (every -launched run lands in `--repo`, so GitHub is always required), the `cli:` of +launched run lands in `--repo`, so the target's GitHub or GitLab integration +is required), the `cli:` of each `f.agent`/`f.llm` call in the default body (else the nearest `flows.json` `cli`, else `claude`), and `tools.mcp`. Handler bodies are not statically scanned for requirements. Authored input never selects an extension handler: diff --git a/packages/sdk/src/cli-commands.ts b/packages/sdk/src/cli-commands.ts index 001c51d9..75a3a0fb 100644 --- a/packages/sdk/src/cli-commands.ts +++ b/packages/sdk/src/cli-commands.ts @@ -156,7 +156,7 @@ export const CLI_VERBS = [ args: [{ name: 'flow', description: 'flow.ts for a hosted listener, or @sha256: for a bundle', required: true }], options: [ { flags: '--to ', description: 'Destination file bucket for a sealed bundle' }, - { flags: '--repo ', description: 'Repository the hosted listener watches' }, + { flags: '--repo ', description: 'Target repository: owner/name or github:owner/name for GitHub; gitlab:group/project (subgroups allowed) or a github.com/gitlab.com HTTP(S) URL' }, { flags: '--on ', description: 'Trigger source, as [:key=value,...]; repeatable' }, { flags: '--approver ', description: 'Handle delivered to every launched run as input.approver' }, { flags: '--agents ', description: 'Agent harnesses to allow, as claude[,codex]' }, diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index b1cf680b..7989f596 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -125,7 +125,7 @@ const USAGE = [ 'flows plugin update [--json] [--yes] [--to ] []', 'flows build [--out ] ', 'flows build --verify ', - 'flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--plugin ] [--no-connect] [--json]', + 'flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--plugin ] [--no-connect] [--json]', 'flows deploy --flow [--plugin ] [--no-connect] [--json]', 'flows deployments [--json]', 'flows versions [--json] ', diff --git a/packages/sdk/src/cli/cloud-deploy.ts b/packages/sdk/src/cli/cloud-deploy.ts index 60971ee5..59adb509 100644 --- a/packages/sdk/src/cli/cloud-deploy.ts +++ b/packages/sdk/src/cli/cloud-deploy.ts @@ -1,7 +1,7 @@ import { CloudFlowError } from '../cloud-http.js'; import { deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud, - type FlowTriggerSource, + type DeployRepository, type FlowTriggerSource, } from '../cloud-deploy.js'; import { describeFlowRequirements } from '../flow-requirements.js'; import { describeVersionChange } from '../cloud-versions-wire.js'; @@ -27,7 +27,7 @@ export interface CloudDeployArgs { } /** - * `flows deploy --repo --on [:k=v,…] [--on …] + * `flows deploy --repo --on [:k=v,…] [--on …] * --approver [--name ] [--agents ] [--draft] [--no-connect] [--json]` * `flows deploy --flow [--plugin ] [--no-connect] [--json]` * @@ -102,6 +102,10 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs | return { command: 'cloud-deploy', value, flow, repo, on, approver, name, agents, draft, noConnect, json, plugins }; } +function describeRepository(repo: DeployRepository): string { + return `${repo.host === 'gitlab' ? 'gitlab:' : ''}${repo.owner}/${repo.name}`; +} + function describeSource(source: FlowTriggerSource): string { const settings = Object.entries(source.settings).map(([k, v]) => `${k}=${v}`).join(' '); return settings ? `${source.provider} ${settings}` : source.provider; @@ -140,7 +144,7 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}` + (deployment.version === undefined ? '' : ` · ${describeVersionChange(deployment.version)}`)); io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`); - io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`); + io.stdout(` repository: ${describeRepository(deployment.repository)}`); for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`); const requires = describeFlowRequirements(deployment.requirements); if (requires) io.stdout(` requires: ${requires}`); @@ -175,7 +179,7 @@ export async function runCloudDeploymentsCli({ json }: { json: boolean }, io: Cl return 0; } for (const d of deployments) { - const repo = d.repository ? ` ${d.repository.owner}/${d.repository.name}` : ''; + const repo = d.repository ? ` ${describeRepository(d.repository)}` : ''; io.stdout(`${d.agentId} ${d.status} ${JSON.stringify(d.name)}${repo}`); for (const source of d.sources) io.stdout(` on: ${describeSource(source)}`); } diff --git a/packages/sdk/src/cloud-deploy.ts b/packages/sdk/src/cloud-deploy.ts index 31bfb38f..267edc61 100644 --- a/packages/sdk/src/cloud-deploy.ts +++ b/packages/sdk/src/cloud-deploy.ts @@ -18,10 +18,11 @@ import { parseVersionChange, type CloudFlowVersionChange } from './cloud-version * deploy wizard. `POST /api/v1/flows/deploy` stores one self-contained * authored source and creates a proactive listener whose watch rules match * the chosen ticket sources on the workspace's relayfile projections. There - * is no webhook to register: the GitHub App installation (or Slack/Linear/ - * Jira/Shortcut connection) is the ingress, and each matching ticket launches - * a run of the stored source with `{ approver, issue, event }` as its input, - * inside a fresh branch of the deployment's repository. + * is no webhook to register: the GitHub App installation (or the GitLab, + * Slack, Linear, Jira or Shortcut connection) is the ingress, and each + * matching ticket launches a run of the stored source with + * `{ approver, issue, event }` as its input, inside a fresh branch of the + * deployment's repository. */ export const FLOW_TRIGGER_PROVIDERS = ['github', 'gitlab', 'linear', 'jira', 'shortcut', 'slack'] as const; @@ -45,6 +46,25 @@ const MAX_SOURCE_BYTES = 256_000; const MAX_SETTING_LENGTH = 500; const REPO_OWNER = /^[A-Za-z0-9-]{1,39}$/u; const REPO_NAME = /^[A-Za-z0-9_.-]{1,100}$/u; +// GitLab coordinates mirror Cloud's isValidFlowRepositoryCoordinates +// (AgentWorkforce/cloud packages/web/lib/flows/flow-repository.ts): the owner +// is the namespace path, at most 255 characters and 20 segments (the root +// group counts; the project name does not), and every owner segment and the +// project name match GITLAB_SEGMENT without a trailing ., .git or .atom. +const GITLAB_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/u; +const GITLAB_OWNER_SEGMENTS = 20; +const GITLAB_OWNER_LENGTH = 255; + +function validGitlabSegment(segment: string): boolean { + return GITLAB_SEGMENT.test(segment) + && !segment.endsWith('.') && !segment.endsWith('.git') && !segment.endsWith('.atom'); +} + +export interface DeployRepository { + owner: string; + name: string; + host?: 'gitlab'; +} export interface FlowTriggerSource { provider: FlowTriggerProvider; @@ -53,7 +73,7 @@ export interface FlowTriggerSource { export interface DeployToCloudInput { path: string; - repository: { owner: string; name: string }; + repository: DeployRepository; sources: FlowTriggerSource[]; /** The `f.human` approver handle every launched run receives as `input.approver`. */ approver: string; @@ -98,7 +118,7 @@ export interface CloudDeployment { agentId: string; name: string; status: string; - repository: { owner: string; name: string }; + repository: DeployRepository; sources: FlowTriggerSource[]; sourceSha256: string; /** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */ @@ -109,12 +129,24 @@ export interface CloudDeployment { version?: CloudFlowVersionChange; } -export function parseRepository(value: string): { owner: string; name: string } { - const [owner, name, extra] = value.replace(/^https?:\/\/github\.com\//iu, '').replace(/\.git$/iu, '').split('/'); - if (!owner || !name || extra !== undefined || !REPO_OWNER.test(owner) || !REPO_NAME.test(name)) { - throw new CloudFlowError('invalid_input', `Expected --repo /, got "${value}".`); +export function parseRepository(value: string): DeployRepository { + const prefix = /^(github|gitlab):/iu.exec(value); + const path = prefix ? value.slice(prefix[0].length) : value; + const url = /^https?:\/\/(github|gitlab)\.com\//iu.exec(path); + const host = (prefix?.[1] ?? url?.[1] ?? 'github').toLowerCase(); + const parts = (url ? path.slice(url[0].length) : path).replace(/\.git$/iu, '').split('/'); + const name = parts.at(-1)!; + const owner = parts.slice(0, -1).join('/'); + const valid = host === 'gitlab' + ? parts.length >= 2 && parts.length - 1 <= GITLAB_OWNER_SEGMENTS + && owner.length <= GITLAB_OWNER_LENGTH && parts.every(validGitlabSegment) + : parts.length === 2 && REPO_OWNER.test(owner) && REPO_NAME.test(name); + if (!valid || (url && url[1]!.toLowerCase() !== host)) { + throw new CloudFlowError('invalid_input', + `Expected --repo (GitHub) or gitlab: (up to ${GITLAB_OWNER_SEGMENTS} namespace segments), ` + + `or a github.com/gitlab.com HTTP(S) project URL, got "${value}".`); } - return { owner, name }; + return { owner, name, ...(host === 'gitlab' ? { host: 'gitlab' as const } : {}) }; } /** `github`, `github:labels=agent,contains=urgent`, `slack:channel=#eng`. */ @@ -233,11 +265,21 @@ export async function deployToCloud( if (!approver) throw new CloudFlowError('invalid_input', '--approver must name who approves f.human questions.'); const name = (input.name ?? definition.name).trim(); if (!name || name.length > 100) throw new CloudFlowError('invalid_input', 'Deployment name must be 1-100 characters.'); - // A GitHub source scoped to nothing would wake on every repository the - // installation covers; default it to the deployment's own repository. - const sources = input.sources.map(s => s.provider === 'github' && s.settings['repository'] === undefined - ? { ...s, settings: { ...s.settings, repository: `${input.repository.owner}/${input.repository.name}` } } - : s); + // Scope same-host sources to the target rather than waking on every project. + const target = `${input.repository.owner}/${input.repository.name}`; + const sources = input.sources.map(s => { + if (s.provider === 'github' && s.settings['repository'] === undefined) { + if (input.repository.host === 'gitlab') { + throw new CloudFlowError('invalid_input', + 'A GitLab target needs an explicit --on github:repository=owner/name for a GitHub source.'); + } + return { ...s, settings: { ...s.settings, repository: target } }; + } + if (s.provider === 'gitlab' && s.settings['project'] === undefined && input.repository.host === 'gitlab') { + return { ...s, settings: { ...s.settings, project: target } }; + } + return s; + }); options.signal?.throwIfAborted(); const whoami = await cloudRequest('/api/v1/auth/whoami', options); @@ -245,8 +287,7 @@ export async function deployToCloud( if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) { throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.'); } - // Every launched run lands in the deployment's repository, so GitHub is - // required even when no GitHub source wakes it. + // Every launched run needs the target's host, even when another provider wakes it. const requirements = mergeFlowExtensionRequirements( flowRequirements(definition, { sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }), @@ -307,7 +348,7 @@ export interface CloudDeploymentSummary { agentId: string; name: string; status: string; - repository?: { owner: string; name: string }; + repository?: DeployRepository; sources: FlowTriggerSource[]; createdAt?: string; updatedAt?: string; @@ -323,7 +364,8 @@ export async function listCloudDeployments(options: CloudConnectionOptions = {}) throw new CloudFlowError('invalid_response', 'Cloud returned a malformed deployment row.'); } const repository = isCloudRecord(row.repository) && typeof row.repository.owner === 'string' - && typeof row.repository.name === 'string' ? { owner: row.repository.owner, name: row.repository.name } : undefined; + && typeof row.repository.name === 'string' ? { owner: row.repository.owner, name: row.repository.name, + ...(row.repository.host === 'gitlab' ? { host: 'gitlab' as const } : {}) } : undefined; const sources = Array.isArray(row.sources) ? row.sources.flatMap((s): FlowTriggerSource[] => isCloudRecord(s) && typeof s.provider === 'string' && (FLOW_TRIGGER_PROVIDERS as readonly string[]).includes(s.provider) ? [{ provider: s.provider as FlowTriggerProvider, diff --git a/packages/sdk/src/cloud-versions.ts b/packages/sdk/src/cloud-versions.ts index 4af98098..39ebe81a 100644 --- a/packages/sdk/src/cloud-versions.ts +++ b/packages/sdk/src/cloud-versions.ts @@ -4,7 +4,7 @@ import { CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions, } from './cloud-http.js'; import { - FLOW_AGENT_HARNESSES, listCloudDeployments, loadDeploySource, type FlowTriggerSource, + FLOW_AGENT_HARNESSES, listCloudDeployments, loadDeploySource, type DeployRepository, type FlowTriggerSource, } from './cloud-deploy.js'; import { parseVersion, parseVersionChange, type CloudFlowVersion, type CloudFlowVersionChange, @@ -25,7 +25,7 @@ export interface CloudListener { agentId: string; name: string; status: string; - repository: { owner: string; name: string; host?: 'gitlab' }; + repository: DeployRepository; sources: FlowTriggerSource[]; activeVersion: CloudFlowVersion | null; versions: CloudFlowVersion[]; diff --git a/packages/sdk/src/flow-requirements.ts b/packages/sdk/src/flow-requirements.ts index 1f381a62..59f33cff 100644 --- a/packages/sdk/src/flow-requirements.ts +++ b/packages/sdk/src/flow-requirements.ts @@ -51,8 +51,8 @@ export interface FlowRequirements { export interface FlowRequirementsContext { /** Trigger sources the deployment listens on (`--on`, or the wizard's chosen sources). */ sources?: readonly { provider: string }[]; - /** Set when the deployment targets a repository: every launched run needs GitHub. */ - repository?: boolean | { owner: string; name: string }; + /** Set when the deployment targets a repository: every launched run needs its host (true means GitHub). */ + repository?: boolean | { owner: string; name: string; host?: 'gitlab' }; /** The nearest `flows.json` `cli`, when one applies. */ projectCli?: string; } @@ -180,7 +180,10 @@ export function flowRequirements( for (const source of context.sources ?? []) { declare({ provider: source.provider, from: 'source', detail: `--on ${source.provider}` }); } - if (context.repository) declare({ provider: 'github', from: 'source', detail: 'deploy target' }); + if (context.repository) { + const provider = typeof context.repository === 'object' && context.repository.host === 'gitlab' ? 'gitlab' : 'github'; + declare({ provider, from: 'source', detail: 'deploy target' }); + } const uses = [...harnessUses.values()]; return { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 4a214d61..50909fa8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -107,7 +107,7 @@ export { export { prepareCloudSubmission, cloudSubmissionBody, type CloudSubmission } from './cloud-run.js'; export { deployToCloud, listCloudDeployments, undeployFromCloud, parseRepository, parseTriggerSource, FLOW_TRIGGER_PROVIDERS, - type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider, + type DeployRepository, type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider, } from './cloud-deploy.js'; export { ensureIntegrationsConnected, integrationConnected, providerLabel, diff --git a/packages/sdk/tests/cloud-deploy.test.ts b/packages/sdk/tests/cloud-deploy.test.ts index 74e0ccbc..a1c4cdbb 100644 --- a/packages/sdk/tests/cloud-deploy.test.ts +++ b/packages/sdk/tests/cloud-deploy.test.ts @@ -82,12 +82,71 @@ describe('trigger source and repository parsing', () => { expect(() => parseTriggerSource(value)).toThrow(expect.objectContaining({ code: 'invalid_input' })); }); - it('accepts owner/name and GitHub URLs, refusing anything else', () => { - expect(parseRepository('AgentWorkforce/flows')).toEqual({ owner: 'AgentWorkforce', name: 'flows' }); - expect(parseRepository('https://github.com/AgentWorkforce/flows.git')).toEqual({ owner: 'AgentWorkforce', name: 'flows' }); - for (const bad of ['flows', 'a/b/c', 'bad owner/x', '']) { - expect(() => parseRepository(bad)).toThrow(expect.objectContaining({ code: 'invalid_input' })); - } + it.each([ + ['AgentWorkforce/flows', { owner: 'AgentWorkforce', name: 'flows' }], + ['https://github.com/AgentWorkforce/flows.git', { owner: 'AgentWorkforce', name: 'flows' }], + ['github:owner/name', { owner: 'owner', name: 'name' }], + ['http://github.com/o/r', { owner: 'o', name: 'r' }], + ['gitlab/myrepo', { owner: 'gitlab', name: 'myrepo' }], + ['github/docs', { owner: 'github', name: 'docs' }], + ['gitlab:group/project', { owner: 'group', name: 'project', host: 'gitlab' }], + ['gitlab:group/sub/project', { owner: 'group/sub', name: 'project', host: 'gitlab' }], + ['https://gitlab.com/group/sub/project.git', { owner: 'group/sub', name: 'project', host: 'gitlab' }], + ['http://GITLAB.com/g/p', { owner: 'g', name: 'p', host: 'gitlab' }], + ['GITLAB:g/p', { owner: 'g', name: 'p', host: 'gitlab' }], + ['gitlab:https://gitlab.com/g/p', { owner: 'g', name: 'p', host: 'gitlab' }], + ])('parses repository %s', (value, expected) => { + expect(parseRepository(value)).toStrictEqual(expected); + }); + + it.each(['flows', 'a/b/c', 'bad owner/x', '', 'gitlab:project', 'gitlab:', + 'gitlab:a//b', 'gitlab:bad segment/p', 'gitlab:.leading/p', 'gitlab:-leading/p', + `gitlab:${'g'.repeat(101)}/p`, + 'https://gitlab.com/group', 'gitlab:https://github.com/o/r', 'github:https://gitlab.com/g/p', + 'https://gitlab.example.com/g/p', 'git@gitlab.com:g/p.git', 'gitlab:g/p/', + ])('refuses repository %s', (value) => { + expect(() => parseRepository(value)).toThrow(expect.objectContaining({ code: 'invalid_input' })); + }); + + // Cloud's isValidFlowRepositoryCoordinates (AgentWorkforce/cloud + // packages/web/lib/flows/flow-repository.ts), pinned at each boundary. + describe('GitLab coordinates match Cloud', () => { + const owner = (segments: number) => Array(segments).fill('g').join('/'); + // 100 + 1 + 100 + 1 + 53 = 255 characters. + const longOwner = (length: number) => `${'a'.repeat(100)}/${'b'.repeat(100)}/${'c'.repeat(length - 202)}`; + + it.each([ + ['20 owner segments', `gitlab:${owner(20)}/p`, owner(20), 'p'], + ['a 255-character owner', `gitlab:${longOwner(255)}/p`, longOwner(255), 'p'], + ['100-character segments', `gitlab:${'g'.repeat(100)}/${'p'.repeat(100)}`, 'g'.repeat(100), 'p'.repeat(100)], + ['a digit-led segment', 'gitlab:9g/0p', '9g', '0p'], + ['inner dots, dashes and underscores', 'gitlab:g.x-y_z/p.atomic', 'g.x-y_z', 'p.atomic'], + ['a stripped .git URL suffix', 'gitlab:g/p.git', 'g', 'p'], + ])('accepts %s', (_case, value, expectedOwner, expectedName) => { + expect(parseRepository(value)).toStrictEqual({ owner: expectedOwner, name: expectedName, host: 'gitlab' }); + }); + + it.each([ + ['21 owner segments', `gitlab:${owner(21)}/p`], + ['a 256-character owner', `gitlab:${longOwner(256)}/p`], + ['a 101-character name', `gitlab:g/${'p'.repeat(101)}`], + ['an underscore-led owner segment', 'gitlab:_g/p'], + ['an underscore-led name', 'gitlab:g/_p'], + ['a dot-led name', 'gitlab:g/.p'], + ['a dash-led name', 'gitlab:g/-p'], + ['an owner segment ending in .', 'gitlab:g./p'], + ['an owner segment ending in .git', 'gitlab:g/s.git/p'], + ['an owner segment ending in .atom', 'gitlab:g.atom/p'], + ['a name ending in .', 'gitlab:g/p.'], + ['a name ending in .git', 'gitlab:g/p.git.git'], + ['a name ending in .atom', 'gitlab:g/p.atom'], + ])('refuses %s', (_case, value) => { + expect(() => parseRepository(value)).toThrow(expect.objectContaining({ code: 'invalid_input' })); + }); + }); + + it('omits the host key for GitHub', () => { + expect('host' in parseRepository('owner/name')).toBe(false); }); }); @@ -124,6 +183,7 @@ describe('deployToCloud', () => { ], requirements: { integrations: ['github', 'slack'], harnesses: [], mcp: [] }, }); + expect(body.repository).toStrictEqual({ owner: 'AgentWorkforce', name: 'flows' }); expect(body.handoffId).toMatch(/^flows-cli-[a-f0-9]{16}$/u); expect(body.source).toContain("flow<{ issue: { title: string }; approver: string }>('issue-triage'"); expect(body.extensions).toBeUndefined(); @@ -132,6 +192,50 @@ describe('deployToCloud', () => { expect(deployment.sourceSha256).toMatch(/^[a-f0-9]{64}$/u); }); + it.each([ + ['gitlab:labels=x', ['gitlab (--on gitlab)']], + ['linear:team=ENG', ['linear (--on linear)', 'gitlab (deploy target)']], + ])('requires the GitLab target integration with %s', async (trigger, details) => { + const path = await authoredFlow(); + const calls = cloud({ + '/api/v1/auth/whoami': () => WHOAMI, + '/api/v1/flows/deploy': () => ({ status: 201, body: { agentId: 'a', status: 'listening' } }), + }); + const repository = parseRepository('gitlab:group/sub/web'); + const deployment = await deployToCloud({ path, repository, sources: [parseTriggerSource(trigger)], approver: 'k' }); + const body = calls.at(-1)!.body as Record; + expect(body.repository).toStrictEqual({ owner: 'group/sub', name: 'web', host: 'gitlab' }); + const providers = trigger.startsWith('gitlab') ? ['gitlab'] : ['linear', 'gitlab']; + expect(body.requirements).toStrictEqual({ integrations: providers, harnesses: [], mcp: [] }); + expect(calls.map(c => c.path)).toEqual([ + '/api/v1/auth/whoami', ...providers.map(p => `/api/v1/workspaces/ws-1/integrations/${p}/status`), '/api/v1/flows/deploy', + ]); + expect(deployment.requirements.integrations.map(i => `${i.provider} (${i.detail})`)).toEqual(details); + if (trigger.startsWith('gitlab')) { + expect(body.sources).toStrictEqual([{ provider: 'gitlab', settings: { labels: 'x', project: 'group/sub/web' } }]); + } + }); + + it('refuses an unscoped GitHub source for a GitLab target before HTTP', async () => { + const path = await authoredFlow(); + const calls = cloud({}); + await expect(deployToCloud({ path, repository: parseRepository('gitlab:g/p'), sources: [parseTriggerSource('github')], approver: 'k' })) + .rejects.toMatchObject({ code: 'invalid_input', message: expect.stringContaining('--on github:repository=owner/name') }); + expect(calls).toEqual([]); + }); + + it.each([ + ['gitlab:g/p', 'github:repository=other/repo', { repository: 'other/repo' }], + ['gitlab:g/p', 'gitlab:project=other/project', { project: 'other/project' }], + ['o/r', 'gitlab', {}], + ])('preserves explicit or cross-host source scope for %s %s', async (repo, trigger, settings) => { + const path = await authoredFlow(); + cloud({ '/api/v1/auth/whoami': () => WHOAMI, + '/api/v1/flows/deploy': () => ({ status: 201, body: { agentId: 'a', status: 'listening' } }) }); + const result = await deployToCloud({ path, repository: parseRepository(repo), sources: [parseTriggerSource(trigger)], approver: 'k' }); + expect(result.sources[0]!.settings).toStrictEqual(settings); + }); + it('refuses a declared harness Cloud cannot run instead of substituting Claude, unless --agents says so', async () => { const dir = await tempDir('cloud-deploy-gemini-'); await symlink(join(process.cwd(), 'node_modules'), join(dir, 'node_modules'), 'dir'); @@ -212,6 +316,29 @@ describe('flows deploy / flows deployments', () => { expect(out).toContainEqual(' on: github labels=agent repository=AgentWorkforce/flows'); }); + it.each([false, true])('deploys and lists GitLab repositories (json=%s)', async (json) => { + const path = await authoredFlow(); + const repository = { owner: 'group/sub', name: 'web', host: 'gitlab' }; + cloud({ + '/api/v1/auth/whoami': () => WHOAMI, + '/api/v1/flows/deploy': () => ({ status: 201, body: { agentId: 'a', status: 'listening' } }), + '/api/v1/agents/flow-deployments': () => ({ deployments: [{ agentId: 'a', name: 'Garden', status: 'listening', repository, sources: [] }] }), + }); + const out: string[] = []; + const io = { stdout: (line: string) => out.push(line), stderr: (line: string) => out.push(`ERR ${line}`) }; + const flags = json ? ['--json'] : []; + expect(await runCli(['deploy', path, '--repo', 'gitlab:group/sub/web', '--on', 'gitlab:labels=x', '--approver', 'k', ...flags], io), out.join('\n')).toBe(0); + if (json) expect(JSON.parse(out[0]!).repository).toStrictEqual(repository); + else { + expect(out).toContain(' repository: gitlab:group/sub/web'); + expect(out).toContain(' requires: gitlab (--on gitlab)'); + } + out.length = 0; + expect(await runCli(['deployments', ...flags], io)).toBe(0); + if (json) expect(JSON.parse(out[0]!).deployments[0].repository).toStrictEqual(repository); + else expect(out).toEqual(['a listening "Garden" gitlab:group/sub/web']); + }); + it('passes --agents and --draft through, and names a structured refusal', async () => { const path = await authoredFlow('drafted'); const calls = cloud({ @@ -401,6 +528,26 @@ describe('flow versions (cloud#4115)', () => { expect(calls.some(call => call.path === '/api/v1/flows/deploy')).toBe(false); }); + it('updates a GitLab listener without adding a target integration requirement', async () => { + const path = await authoredFlow(); + const calls = cloud({ + [`/api/v1/flows/listeners/${LISTENER}`]: () => ({ ...LISTENER_DETAIL, listener: { + ...LISTENER_DETAIL.listener, repository: { owner: 'group/sub', name: 'web', host: 'gitlab' }, + sources: [{ provider: 'linear', settings: { team: 'ENG' } }], + } }), + '/api/v1/auth/whoami': () => WHOAMI, + [`/api/v1/flows/listeners/${LISTENER}/versions`]: () => ({ listenerId: LISTENER, status: 'listening', + version: { versionId: 'v4', version: 4, previousVersion: 3, change: 'created' } }), + }); + const { out, io: cliIo } = io(); + expect(await runCli(['deploy', path, '--flow', LISTENER], cliIo), out.join('\n')).toBe(0); + const post = calls.find(c => c.method === 'POST')!; + expect(Object.keys(post.body as object).sort()).toEqual(['requirements', 'source', 'workspaceId']); + expect(post.body).toMatchObject({ requirements: { integrations: ['linear'] } }); + expect(calls.filter(c => c.path.includes('/integrations/')).map(c => c.path)) + .toEqual(['/api/v1/workspaces/ws-1/integrations/linear/status']); + }); + it('takes a listener id directly and says when the active version went down', async () => { const path = await authoredFlow('garden'); const calls = cloud({ diff --git a/packages/sdk/tests/relay-cli-surface.test.ts b/packages/sdk/tests/relay-cli-surface.test.ts index 95f12139..cbb9b27d 100644 --- a/packages/sdk/tests/relay-cli-surface.test.ts +++ b/packages/sdk/tests/relay-cli-surface.test.ts @@ -93,6 +93,7 @@ const INVOCATIONS: readonly { verb: string; argv: readonly string[]; variant: Pa '--plugin', 'github:o/r@main#path', '--no-connect', '--json'], variant: 'cloud-deploy', }, + { verb: 'deploy', argv: ['deploy', 'review.flow.ts', '--repo', 'gitlab:group/sub/project', '--on', 'gitlab', '--approver', 'someone'], variant: 'cloud-deploy' }, // The update form: the source becomes the next version of an existing flow. { verb: 'deploy', argv: ['deploy', 'review.flow.ts', '--flow', 'review-listener'], variant: 'cloud-deploy' }, { @@ -281,6 +282,15 @@ describe('relay-cli surface: drift between `commands` and `run`', () => { const surface = createRelayCliSurface(); const declaredTopLevel = surface.commands.map((command) => command.name); + it('documents both repository hosts and the explicit prefixes in deploy help', () => { + const repo = CLI_VERBS.find(verb => verb.name === 'deploy')!.options! + .find(option => option.flags.startsWith('--repo '))!; + expect(repo.flags).toBe('--repo '); + expect(repo.description).toContain('github:owner/name'); + expect(repo.description).toContain('gitlab:group/project'); + expect(repo.description).toContain('github.com/gitlab.com HTTP(S) URL'); + }); + it('declares exactly the verbs the parser dispatches', () => { // Both sides read CLI_VERBS, so this pins the projection rather than a // second list: `commands` must lose nothing on its way to the host.