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: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

162 changes: 147 additions & 15 deletions web/lib/flow-workflows.ts

Large diffs are not rendered by default.

31 changes: 27 additions & 4 deletions web/lib/test/flow-onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_FREE_DISK_COMMAND, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PREPARE_CHANGE_METADATA_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REPORT_REVIEW_FINDINGS_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND, FLOW_VALIDATE_CHANGE_METADATA_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';
import { FLOW_PUSH_COMMAND } from '../flow-workflows';
import { FLOW_PUSH_COMMAND, FLOW_REFERENCE_LIMIT } from '../flow-workflows';

// Every push goes through the workflow-file guard (run 065fd98f).
const PUSH = 'base=abc123; ' + FLOW_PUSH_COMMAND + ' --set-upstream origin HEAD';
Expand All @@ -25,9 +29,9 @@ const withoutComments = (source: string) => source.split('\n').filter(line => !l
* each run of the repository's checks reports, in order (pass once the list is
* used up), and `baseline` what the base commit's check reports. Three
* commands are built as `base=<commit>; ...`, so the mock matches each by the
* command it ends with.
* command it ends with. `validate` answers the metadata validation step.
*/
async function runFactory(clean: boolean[], _approved = true, issue = matchingIssue, draft = completed, publish = 'publish', checks: string[] = [], baseline = 'pass', edit = (source: string) => source) {
async function runFactory(clean: boolean[], _approved = true, issue = matchingIssue, draft = completed, publish = 'publish', checks: string[] = [], baseline = 'pass', edit = (source: string) => source, validate = (_command: string) => 'valid') {
const calls: string[] = [];
const errors: string[] = [];
let finish = '';
Expand All @@ -48,7 +52,7 @@ async function runFactory(clean: boolean[], _approved = true, issue = matchingIs
if (command === FLOW_CHECK_RUN_COMMAND) return checks[checkIndex++] ?? 'pass';
if (command.endsWith(FLOW_BASE_CHECK_COMMAND)) return baseline;
if (command.endsWith(FLOW_PUBLISH_CHECK_COMMAND)) return publish;
if (command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)) return 'valid';
if (command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)) return validate(command);
if (command === FLOW_REPORT_REVIEW_FINDINGS_COMMAND) return 'relayflow report-review-findings: review.clean absent; remaining findings from review.md:\nOne P2 remains.\n';
return command.startsWith('test -f') ? (clean[index++] ? 'yes' : 'no') : command.startsWith('mktemp') ? '/tmp/relay-prototypes.test' : command === 'git rev-parse HEAD' ? 'abc123' : '';
},
Expand Down Expand Up @@ -345,6 +349,25 @@ describe('software factory onboarding', () => {
expect(errors.join('\n')).toContain('no commits');
});

it('stops before any push or pull request when the closing reference is too long (agentrelay.com#160)', async () => {
// The real validation command decides, as the generated flow calls it.
const root = mkdtempSync(path.join(tmpdir(), 'flow-reference-'));
try {
mkdirSync(path.join(root, '.relayflow'));
writeFileSync(path.join(root, '.relayflow/pr-body.md'), 'Fixed it.\n\nFixes #507\n');
const real = (command: string) => spawnSync('/bin/sh', ['-c', command], { cwd: root, encoding: 'utf8' }).stdout.trim();
const runaway = { ...matchingIssue, identifier: '#' + '1'.repeat(FLOW_REFERENCE_LIMIT), url: '' };
const { calls, finish, errors } = await runFactory([true, true], true, runaway, completed, 'publish', [], 'pass', source => source, real);
expect(calls.some(call => call.includes(FLOW_PUSH_COMMAND))).toBe(false);
expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false);
expect(finish).toBe('needs_human');
expect(errors.join('\n')).toContain('invalid pull-request metadata (reference-too-long)');
// The same harness with an ordinary reference publishes.
const ordinary = await runFactory([true, true], true, matchingIssue, completed, 'publish', [], 'pass', source => source, real);
expect(ordinary.calls).toContain(PUSH);
} finally { rmSync(root, { recursive: true, force: true }); }
});

it('pushes the work but opens no pull request when no summary.md was written', async () => {
// Commits exist, so the work is real and worth pushing; the pull request
// body is what is missing, and `gh pr create --body-file summary.md` would
Expand Down
41 changes: 38 additions & 3 deletions web/lib/test/flow-push-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { FLOW_PUSH_COMMAND, WORKFLOW_FILES_HINT } from '../flow-workflows';
import { FLOW_BODY_LIMIT, FLOW_PUSH_COMMAND, FLOW_WITHHELD_NOTICE_RESERVE, WORKFLOW_FILES_HINT } from '../flow-workflows';
import { factorySource, DEFAULT_FACTORY, type FactoryDraft } from '../flow-onboarding';

/**
Expand Down Expand Up @@ -329,7 +329,7 @@ describe('FLOW_PUSH_COMMAND', { timeout: 30_000 }, () => {
});

// Review on #114: the whole section, not just the patch, must fit GitHub's
// 65,536-character body limit.
// 65,536-character body limit, within the flow's own cap (agentrelay.com#160).
it('keeps the pull-request body under GitHub\'s limit even with a long file list', () => {
const files: Record<string, string> = { 'README.md': '#\n' };
const edits: Record<string, string> = { 'src/x.ts': 'x\n' };
Expand All @@ -344,11 +344,46 @@ describe('FLOW_PUSH_COMMAND', { timeout: 30_000 }, () => {
const result = push(root, base);
expect(result.code).toBe(0);
const body = read(root, '.relayflow/pr-body.md');
expect(body.length).toBeLessThanOrEqual(65536);
expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT);
expect(body).toContain('…and 70 more');
expect(body.split('````').length % 2).toBe(1);
}, 60_000);

// Review on #161: a near-full body and 50 long workflow paths must not
// overflow it either; the whole section is capped, not just the patch.
it('keeps a near-full pull-request body under the cap with 50 long workflow paths', () => {
const files: Record<string, string> = { 'README.md': '#\n' };
const edits: Record<string, string> = { 'src/x.ts': 'x\n' };
for (let i = 0; i < 50; i += 1) {
const name = `.github/workflows/${'w'.repeat(200)}-${i}.yml`;
files[name] = 'name: w\n';
edits[name] = 'name: w\n# edited\n';
}
const { root, base } = setup(files);
commit(root, 'long workflow paths', edits);
write(root, { '.relayflow/pr-body.md': BODY + 'x\n'.repeat(29000) });
const result = push(root, base);
expect(result.code).toBe(0);
const body = read(root, '.relayflow/pr-body.md');
expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT);
expect(body).toContain('## Workflow changes not applied');
expect(body).toMatch(/truncated to fit GitHub's limit/);
}, 60_000);

// Review on #161: a body prepared as full as it may be still leaves room to
// say the workflow edits were withheld.
it('still says the workflow edits were withheld when the prepared body is as full as it may be', () => {
const { root, base } = setup({ 'README.md': '#\n', '.github/workflows/ci.yml': CI });
commit(root, 'one', { 'src/x.ts': 'x\n', '.github/workflows/ci.yml': CI_EDITED });
const filler = FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE - Buffer.byteLength(BODY);
write(root, { '.relayflow/pr-body.md': BODY + 'x'.repeat(filler - 1) + '\n' });
expect(push(root, base).code).toBe(0);
const body = read(root, '.relayflow/pr-body.md');
expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT);
expect(body).toContain('## Workflow changes not applied');
expect(body).toContain('.github/workflows/ci.yml');
}, 60_000);

it('bounds the patch in the pull-request body and says where the rest is', () => {
const { root, base } = setup({ 'README.md': '#\n', '.github/workflows/ci.yml': CI });
commit(root, 'ci', { '.github/workflows/ci.yml': CI + '# padding\n'.repeat(4000), 'docs/a.md': 'a\n' });
Expand Down
Loading
Loading