diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 38c797fb5..d0418c1d0 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -452,11 +452,15 @@ listener's rules match them there. The digest form, decides which form is meant. `--on [:key=value,…]` takes `github` (`repository`, `labels`, -`contains`, `events`), `slack` (`channel`, `contains`), `linear` (`team`, -`contains`), `jira` (`project`, `contains`) or `shortcut` (`workspace`, -`contains`), each at most once. A GitHub source without `repository` is +`contains`, `events`, and the pull-request subscription opt-outs `reviews`, +`checks`, `comments`), `gitlab` (`project`, `labels`, `contains`, `events`), +`slack` (`channel`, `contains`), `linear` (`team`, `project`, `labels`, +`contains`), `jira` (`project`, `labels`, `contains`) or `shortcut` +(`workspace`, `team`, `labels`, `contains`), each at most once. A Linear +`team` matches the team's name or its key. A GitHub source without +`repository` is scoped to `--repo`. `events` is `issues` (the default: `issues.opened` and -`issues.labeled`) or `pull_request`, which wakes on a pull request being +`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 `input.pullRequest` (`owner`, `repo`, `number`, `action`, `title`, `body`, `headRef`, `headSha`, diff --git a/examples/software-factory/README.md b/examples/software-factory/README.md index 517945073..6873559af 100644 --- a/examples/software-factory/README.md +++ b/examples/software-factory/README.md @@ -1,8 +1,8 @@ # software-factory -[![Deploy Flow](https://agentrelay.com/launch-agent_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fmain%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&on=linear%3Ateam%3DENG) +[![Deploy Flow](https://agentrelay.com/launch-agent_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fmain%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&on=linear) -One click deploys this flow to [Agent Relay Cloud](https://agentrelay.com/cloud), running on every new Linear issue in team `ENG`. +One click deploys this flow to [Agent Relay Cloud](https://agentrelay.com/cloud), running on new Linear issues — set your team on the deploy page. A ticket becomes a pull request: implementation agent → deterministic tests → adversarial review agent → PR opened for a human. The review verdict is a file @@ -12,18 +12,27 @@ neither can be talked into a green result. ```sh flows check examples/software-factory/software-factory.flow.ts flows deploy examples/software-factory/software-factory.flow.ts \ - --repo acme/api --on linear:team=ENG --approver you + --repo acme/api --on linear:team=TECH --approver you flows deployments ``` `--on` also takes `github:labels=agent`, `jira:project=OPS`, `shortcut:workspace=…` -or `slack:channel=#eng`. Each matching ticket launches one Cloud run in a fresh +or `slack:channel=#eng`, and `linear` accepts `team`, `project`, `labels` and +`contains` (`linear:team=TECH,labels=agent`); `team` matches the Linear team +name or its key, so `team=Engineering` and `team=TECH` are the same filter. The +deploy page shows every filter as an editable field — the badge only fixes the +provider; team, project and labels are filled in there. + +Each matching ticket launches one Cloud run in a fresh `relayflow/software-factory-` branch of `--repo`; a passing review opens a PR, a blocked one opens a draft PR carrying the findings and ends `step_failed`. The pull-request title is the ticket title (whitespace-normalized and capped at 240 Unicode code points). GitHub inputs must carry `identifier: "#"`; the flow appends exactly one `Fixes #` line and validates the final -title and body before it pushes the branch or opens the pull request. +title and body before it pushes the branch or opens the pull request. A Linear +identifier (`TECH-42`) gets the same treatment — `Fixes TECH-42` is what +Linear's GitHub integration reads to link the pull request to the issue and +move it when the PR merges. Locally, from a checkout on a scratch branch: diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts index fe8b61a4d..307a4e2b7 100644 --- a/examples/software-factory/software-factory.flow.ts +++ b/examples/software-factory/software-factory.flow.ts @@ -40,7 +40,14 @@ const VALIDATE_CHANGE_METADATA = [ "elif [ \"$title_length\" -gt 240 ]; then echo title-too-long", "elif [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"software factory change\" ] || [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"replace with your ticket title\" ]; then echo placeholder-title", "elif [ \"$source\" = github ] && ! printf \"%s\\n\" \"$identifier\" | grep -Eq \"^#[1-9][0-9]*$\"; then echo malformed-github-identifier", - `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, + `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) +([^[:space:]]*#[0-9]+|[A-Za-z][A-Za-z0-9]*-[0-9]+|https?://)" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, + // A Linear identifier ("TECH-42" — a team key can carry digits, like + // "PLA4-42") earns the same contract: "Fixes TECH-42" is what Linear's + // GitHub integration reads to link the pull request back to the issue and + // move it when the PR merges. A missing or malformed one stops the run + // rather than open a pull request nothing can link. + `elif [ "$source" = linear ] && ! printf "%s\\n" "$identifier" | grep -Eq "^[A-Za-z][A-Za-z0-9]*-[0-9]+$"; then echo malformed-linear-identifier`, + `elif [ "$source" = linear ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) +([^[:space:]]*#[0-9]+|[A-Za-z][A-Za-z0-9]*-[0-9]+|https?://)" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, "else echo valid", "fi", ].join("; "); @@ -77,7 +84,16 @@ export default flow("software-factory", { await f.run("echo 'Stopped: a GitHub ticket must carry its normalized identifier in # form.' >&2"); return f.done("needs_human"); } - const changeReference = issueSource === "github" + // A Linear identifier is the write-back hook: Linear's GitHub integration + // links the pull request to the issue and moves it on merge when the body + // carries `Fixes TECH-42`. A bare ticket URL does not link anything, so a + // Linear ticket without one (team keys can carry digits: "PLA4-42") stops + // here rather than open a pull request nothing links. + if (issueSource === "linear" && !/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(issueIdentifier)) { + await f.run("echo 'Stopped: a Linear ticket must carry its identifier (like TECH-42) so the pull request can link back.' >&2"); + return f.done("needs_human"); + } + const changeReference = issueSource === "github" || issueSource === "linear" ? `Fixes ${issueIdentifier}` : issueSource === "gitlab" && /^#[1-9]\d*$/.test(issueIdentifier) ? `Closes ${issueIdentifier}` diff --git a/packages/sdk/src/cloud-deploy.ts b/packages/sdk/src/cloud-deploy.ts index a83d3f94e..1339e1b4f 100644 --- a/packages/sdk/src/cloud-deploy.ts +++ b/packages/sdk/src/cloud-deploy.ts @@ -23,18 +23,20 @@ import { assertNoUseDependencies, collectExtensionSubmissions } from './flow-ext * inside a fresh branch of the deployment's repository. */ -export const FLOW_TRIGGER_PROVIDERS = ['github', 'linear', 'jira', 'shortcut', 'slack'] as const; +export const FLOW_TRIGGER_PROVIDERS = ['github', 'gitlab', 'linear', 'jira', 'shortcut', 'slack'] as const; export type FlowTriggerProvider = (typeof FLOW_TRIGGER_PROVIDERS)[number]; /** Settings Cloud's launcher prefilter reads per provider (`flow-trigger-sources.ts`). */ const PROVIDER_SETTINGS: Record = { // `events`: `issues` (default) or `pull_request` — which GitHub records - // wake the listener (AgentWorkforce/cloud#3772). - github: ['repository', 'labels', 'contains', 'events'], + // wake the listener (AgentWorkforce/cloud#3772). `reviews`, `checks` and + // `comments` opt a pull_request source out of its extra subscriptions. + github: ['repository', 'labels', 'contains', 'events', 'reviews', 'checks', 'comments'], + gitlab: ['project', 'labels', 'contains', 'events'], slack: ['channel', 'contains'], - linear: ['team', 'contains'], - jira: ['project', 'contains'], - shortcut: ['workspace', 'contains'], + linear: ['team', 'project', 'labels', 'contains'], + jira: ['project', 'labels', 'contains'], + shortcut: ['workspace', 'team', 'labels', 'contains'], }; const MAX_SOURCE_BYTES = 256_000; const MAX_SETTING_LENGTH = 500; @@ -135,12 +137,23 @@ export function parseTriggerSource(value: string): FlowTriggerSource { if (key === 'events') { // Cloud's enum is lowercase; send it that way whatever the shell typed. const events = setting.toLowerCase(); - if (!['issues', 'pull_request'].includes(events)) { - throw new CloudFlowError('invalid_input', `github events must be "issues" or "pull_request", got "${setting}".`); + const valid = provider === 'gitlab' ? ['issues', 'merge_request'] : ['issues', 'pull_request']; + if (!valid.includes(events)) { + throw new CloudFlowError('invalid_input', + `${provider} events must be ${valid.map(v => `"${v}"`).join(' or ')}, got "${setting}".`); } settings[key] = events; continue; } + if (provider === 'github' && (key === 'reviews' || key === 'checks' || key === 'comments')) { + // Subscription opt-outs are boolean; Cloud refuses any other value. + const toggle = setting.toLowerCase(); + if (!['true', 'false'].includes(toggle)) { + throw new CloudFlowError('invalid_input', `github ${key} must be "true" or "false", got "${setting}".`); + } + settings[key] = toggle; + continue; + } settings[key] = setting; } } diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 9fa18aabd..7c0507f46 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -26,7 +26,7 @@ const REALPATH = realpath; const PATH_DIRNAME = dirname; const PATH_JOIN = join; const PATH_RESOLVE = resolve; -const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; +const SOFTWARE_FACTORY_SHA256 = 'b97a3466c2affabb61afa655d4b2f726942d753c0466740b64ba26a78da359c6'; const ARRAY_IS_ARRAY = Array.isArray; const OBJECT_FREEZE = Object.freeze; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( diff --git a/packages/sdk/tests/canonical-software-factory.test.ts b/packages/sdk/tests/canonical-software-factory.test.ts index 038712e2f..50af06eea 100644 --- a/packages/sdk/tests/canonical-software-factory.test.ts +++ b/packages/sdk/tests/canonical-software-factory.test.ts @@ -72,7 +72,9 @@ function runCanonical(issue: Issue, summary = '## Summary\n\nImplemented the tic commands, completionReason, ghArgs: existsSync(capture) ? readFileSync(capture, 'utf8').trim().split('\n') : [], - body: readFileSync(join(root, '.relayflow/pr-body.md'), 'utf8'), + body: existsSync(join(root, '.relayflow/pr-body.md')) + ? readFileSync(join(root, '.relayflow/pr-body.md'), 'utf8') + : '', })); } @@ -95,6 +97,71 @@ describe('canonical software-factory metadata contract', () => { expect(push).toBeLessThan(open); }); + it('writes a Linear closing reference that the GitHub integration links back', async () => { + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'Per-connection 429 budget.', labels: ['agent'], + identifier: 'TECH-42', url: 'https://linear.app/wepost/issue/TECH-42', + }); + expect(result.completionReason).toBe('success'); + expect(result.body.split('\n').filter(line => line === 'Fixes TECH-42')).toHaveLength(1); + }); + + it('accepts a Linear team key that carries digits', async () => { + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'PLA4-42', url: 'https://linear.app/wepost/issue/PLA4-42', + }); + expect(result.completionReason).toBe('success'); + expect(result.body.split('\n').filter(line => line === 'Fixes PLA4-42')).toHaveLength(1); + }); + + it.each(['', 'not-an-issue'])( + 'stops before push when a Linear ticket has no linkable identifier (%s)', + async (identifier) => { + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier, url: 'https://linear.app/wepost/issue/TECH-42', + }); + expect(result.completionReason).toBe('needs_human'); + expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); + }, + ); + + it('fails closed before push when the Linear closing reference is duplicated in the final body', async () => { + // The summary is written by the implementer; a body that repeats the + // closing line must stop the run rather than ship a doubled reference. + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'TECH-42', + }, '## Summary\n\nFixes TECH-42\n\nFixes TECH-42\n'); + expect(result.completionReason).toBe('needs_human'); + expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); + }); + + it('fails closed before push when the body carries a foreign closing reference', async () => { + // A summary naming a different ticket would auto-link the pull request to + // the wrong issue on merge. PREPARE appends the expected line, leaving two + // closing-keyword lines — the run must stop rather than ship both. + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'TECH-42', + }, '## Summary\n\nFixes OTHER-9\n'); + expect(result.completionReason).toBe('needs_human'); + expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); + }); + + it('does not mistake ordinary closing-verb prose for a reference', async () => { + // A summary sentence that starts with a closing verb is not a ticket + // reference — GitHub only links the keyword when an issue reference + // follows it. Counting "Fixed the retry loop" would block the run. + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'TECH-42', + }, '## Summary\n\nFixed the retry loop in the dispatcher.\n'); + expect(result.completionReason).toBe('success'); + expect(result.body.split('\n').filter(line => line === 'Fixes TECH-42')).toHaveLength(1); + }); + it('normalizes whitespace and caps the title at 240 Unicode code points', async () => { const result = await runCanonical({ source: 'github', title: ` Repair ${'修'.repeat(250)} `, body: 'body', labels: [], identifier: '#7', diff --git a/packages/sdk/tests/cloud-deploy.test.ts b/packages/sdk/tests/cloud-deploy.test.ts index b9e8584af..87d641e7a 100644 --- a/packages/sdk/tests/cloud-deploy.test.ts +++ b/packages/sdk/tests/cloud-deploy.test.ts @@ -63,13 +63,19 @@ describe('trigger source and repository parsing', () => { ['github:labels=agent,contains=urgent', { provider: 'github', settings: { labels: 'agent', contains: 'urgent' } }], ['slack:channel=#eng', { provider: 'slack', settings: { channel: '#eng' } }], ['linear:team=ENG', { provider: 'linear', settings: { team: 'ENG' } }], + ['linear:team=Engineering,labels=agent,contains=urgent', { provider: 'linear', settings: { team: 'Engineering', labels: 'agent', contains: 'urgent' } }], + ['jira:project=OPS,labels=agent', { provider: 'jira', settings: { project: 'OPS', labels: 'agent' } }], + ['shortcut:workspace=acme,team=Platform', { provider: 'shortcut', settings: { workspace: 'acme', team: 'Platform' } }], + ['gitlab:project=acme/platform/web', { provider: 'gitlab', settings: { project: 'acme/platform/web' } }], + ['gitlab:events=merge_request,labels=agent', { provider: 'gitlab', settings: { events: 'merge_request', labels: 'agent' } }], ['github:events=pull_request,labels=agent', { provider: 'github', settings: { events: 'pull_request', labels: 'agent' } }], ['github:events=PULL_REQUEST', { provider: 'github', settings: { events: 'pull_request' } }], + ['github:events=pull_request,reviews=False', { provider: 'github', settings: { events: 'pull_request', reviews: 'false' } }], ])('parses %s', (value, expected) => { expect(parseTriggerSource(value)).toEqual(expected); }); - it.each(['gitlab', 'github:channel=x', 'github:labels=', 'github:labels=a,labels=b', 'slack:labels=x', 'github:events=releases']) + it.each(['github:channel=x', 'github:labels=', 'github:labels=a,labels=b', 'slack:labels=x', 'github:events=releases', 'gitlab:events=pull_request', 'gitlab:repository=acme/web', 'github:reviews=maybe', 'linear:events=issues']) ('refuses %s', (value) => { expect(() => parseTriggerSource(value)).toThrow(expect.objectContaining({ code: 'invalid_input' })); });