import assert from 'node:assert/strict'; import test from 'node:test'; import { discover, maintainGoal, type AutomationDependencies } from '../src/application/automation.js'; import { GitHub } from '../src/adapters/github/client.js'; import { configSchema } from '../src/domain/config.js'; import { feedbackDigest, integrateBase } from '../src/domain/feedback.js'; import { taskId } from '../src/domain/identity.js'; import type { GoalState } from '../src/domain/iteration.js'; import type { PullFeedback, QueueIssue } from '../src/ports/automation.js'; import { agent, pr, result, store, verifiedReport } from './helpers.js'; async function fixture() { const config = configSchema.parse({ repository: 'owner/repo', agent: { enabled: true, repair: true }, iteration: { maintenance: { trustedReviewers: ['maintainer'], requiredChecks: ['tests'] }, queue: { labels: ['agent-ready'], trustedAuthors: ['maintainer'], allowedPaths: ['src'] } } }); const state: GoalState = { schemaVersion: 1, id: 'a'.repeat(24), repository: config.repository, spec: { title: 'Implement a feature', objective: 'Implement the requested behavior', mode: 'feature', acceptance: [{ id: 'feature', text: 'Implement the requested behavior' }], allowedPaths: ['src', 'test'] }, configHash: taskId(config), branch: 'main', sha: pr.base.sha, createdAt: '', updatedAt: '', status: 'published', steps: [], completed: [], changes: [], reports: [], rounds: 0, calls: 0, tokens: 0, elapsedMs: 0, notes: [], publication: 'b'.repeat(24), pullRequestUrl: 'https://github.com/owner/repo/pull/1' }; const feedback: PullFeedback = { pr: { ...pr, head: { ...pr.head, ref: `autofix/goal-${state.id}/${state.publication}` }, draft: true }, headRun: state.publication, checks: [{ name: 'tests', status: 'completed', conclusion: 'success' }], comments: [] }; const goals = [state]; const dependencies: AutomationDependencies = { config, store: await store(), signal: new AbortController().signal, goals: { read: async () => state, save: async () => {}, list: async () => goals, pause: async () => {}, paused: async () => false, unpause: async () => {}, remember: async () => {}, experiences: async () => [] }, github: { listPulls: async () => [], pull: async () => feedback.pr, current: async () => undefined, publish: async () => undefined, issue: async () => { throw new Error('Unexpected Issue read'); }, target: async () => ({ branch: 'main', sha: state.sha }), issues: async () => [], feedback: async () => feedback, outcome: async () => ({ pr: feedback.pr, checks: feedback.checks }), updatePull: async () => { throw new Error('Unexpected publish'); }, recoverPull: async () => undefined, propose: async () => { throw new Error('Unexpected proposal'); } }, repository: { resolveCommit: async () => state.sha, prepare: async () => {}, fetch: async () => {}, snapshot: async () => new Map([['src/a.ts', 'GOOD']]) }, runner: { run: async () => result() }, agent: agent() }; return { d: dependencies, state, feedback, goals }; } test('review maintenance never truncates oversized requirements', async () => { const { d, state, feedback } = await fixture(); feedback.comments = [{ id: 'long-review', author: 'maintainer', body: 'x'.repeat(2001) }]; assert.equal((await maintainGoal(state.id, d)).status, 'needs_attention'); assert.equal(state.calls, 0); }); test('base integration preserves unrelated work, advances from the last base and rejects conflicts', () => { const old = new Map([['src/a.ts', 'old'], ['src/b.ts', 'original']]); const base = new Map([['src/a.ts', 'base'], ['src/b.ts', 'original']]); const head = new Map([['src/a.ts', 'old'], ['src/b.ts', 'repair']]); const merged = integrateBase(old, base, head); assert.equal(merged.get('src/a.ts'), 'base'); assert.equal(merged.get('src/b.ts'), 'repair'); const next = new Map(base); next.set('src/a.ts', 'new base'); assert.equal(integrateBase(base, next, merged).get('src/a.ts'), 'new base'); assert.throws(() => integrateBase(old, next, merged), /conflict/); }); test('maintenance waits for configured checks and ignores untrusted reviews', async () => { const { d, state, feedback } = await fixture(); feedback.checks = []; assert.equal((await maintainGoal(state.id, d)).status, 'waiting_for_ci'); feedback.checks = [{ name: 'tests', status: 'completed', conclusion: 'success' }]; feedback.comments = [{ id: 'outside', author: 'stranger', body: 'Please change all behavior' }]; assert.equal((await maintainGoal(state.id, d)).status, 'ready_for_review'); assert.equal(state.calls, 0); }); test('maintenance recognizes its latest integrated base without rerunning work', async () => { const { d, state, feedback } = await fixture(); feedback.pr.base = { ...feedback.pr.base, sha: 'c'.repeat(40) }; state.maintenance = { reportId: 'previous', digest: 'previous', consumed: [], head: feedback.pr.head.sha, base: feedback.pr.base.sha, outcome: 'updated' }; assert.equal((await maintainGoal(state.id, d)).status, 'ready_for_review'); assert.equal(state.rounds, 0); }); test('maintenance rejects renamed, foreign and manually modified heads before execution', async () => { for (const change of ['branch', 'foreign', 'head', 'marker']) { const { d, state, feedback } = await fixture(); if (change === 'branch') feedback.pr.head.ref = 'someone-elses-branch'; if (change === 'foreign') feedback.pr.head.repo = { full_name: 'other/repo' }; if (change === 'marker') feedback.headRun = undefined; if (change === 'head') state.maintenance = { reportId: 'old', digest: 'old', consumed: [], head: 'c'.repeat(40) }; await assert.rejects(maintainGoal(state.id, d), /owned branch|published goal evidence/); assert.equal(state.calls, 0); } }); test('paused goals do not fetch feedback or reserve a budget', async () => { const { d, state } = await fixture(); d.goals.paused = async () => true; d.github.feedback = async () => { throw new Error('Unexpected feedback read'); }; assert.equal((await maintainGoal(state.id, d)).status, 'paused'); assert.equal(state.calls, 0); }); test('failed follow-ups may retry with fresh run keys but remain budget bounded', async () => { const { d, state, feedback } = await fixture(); feedback.checks[0]!.conclusion = 'failure'; d.repository.snapshot = async () => { throw new Error('temporary snapshot failure'); }; await assert.rejects(maintainGoal(state.id, d), /snapshot failure/); const previous = state.maintenance!.reportId; assert.equal(state.maintenance?.outcome, 'failed'); await assert.rejects(maintainGoal(state.id, d), /snapshot failure/); assert.notEqual(state.maintenance!.reportId, previous); state.rounds = d.config.iteration!.maxRounds; await assert.rejects(maintainGoal(state.id, d), /budget exhausted/); }); test('discovery requires an exact preview and publication policy, then deduplicates proposals', async () => { const { d } = await fixture(); const report = verifiedReport(); report.status = 'needs_attention'; report.tests.base = result('failed'); await d.store.save(report); const preview = await discover(d); assert.equal(preview.proposals.length, 1); assert.deepEqual(preview.published, []); await assert.rejects(discover(d, 'wrong'), /preview changed/); await assert.rejects(discover(d, preview.token), /publish=true/); d.config.publish = true; const created: QueueIssue[] = []; d.github.issues = async () => created; d.github.propose = async (title, body) => { created.push({ number: 20, state: 'open', title, body, user: { login: 'bot' }, labels: [], created_at: '' }); return 'https://github.com/owner/repo/issues/20'; }; assert.equal((await discover(d, preview.token)).published.length, 1); assert.deepEqual((await discover(d, preview.token)).published, []); }); test('GitHub follow-up uses a non-force merge commit and returns the exact published SHA', async () => { const { feedback } = await fixture(); const client = new GitHub('owner/repo', 'fixture'); client.feedback = async () => feedback; const writes: { path: string; body: any }[] = []; client.request = async (path: string, method = 'GET', body?: unknown): Promise => { if (method !== 'GET') writes.push({ path, body }); if (path.startsWith('git/commits/')) return { tree: { sha: 'parent-tree' } } as T; if (path.startsWith('git/trees/')) return { truncated: false, tree: [{ path: 'src/a.ts', type: 'blob', mode: '100755' }] } as T; return { sha: path === 'git/trees' ? 'new-tree' : 'new-commit' } as T; }; const published = await client.updatePull(verifiedReport(), 1, feedback.pr.head.sha, feedback.pr.base.sha, feedbackDigest(feedback)); assert.equal(published?.head, 'new-commit'); assert.deepEqual(writes.find(w => w.path === 'git/commits')!.body.parents, [feedback.pr.head.sha, feedback.pr.base.sha]); assert.equal(writes.find(w => w.path.startsWith('git/refs/'))!.body.force, false); assert.equal(writes.find(w => w.path === 'git/trees')!.body.tree[0].mode, '100755'); }); test('GitHub follow-up refuses stale feedback and unsupported tree modes', async () => { const { feedback } = await fixture(); const client = new GitHub('owner/repo', 'fixture'); client.feedback = async () => feedback; client.request = async (path: string, method = 'GET'): Promise => { assert.equal(method, 'GET'); return (path.startsWith('git/commits/') ? { tree: { sha: 'parent-tree' } } : { truncated: false, tree: [{ path: 'src/a.ts', type: 'blob', mode: '120000' }] }) as T; }; assert.equal(await client.updatePull(verifiedReport(), 1, feedback.pr.head.sha, feedback.pr.base.sha, 'stale'), undefined); await assert.rejects(client.updatePull(verifiedReport(), 1, feedback.pr.head.sha, feedback.pr.base.sha, feedbackDigest(feedback)), /file mode/); }); test('publication recovery checks saved report, exact parents and the full committed tree', async () => { const { createHash } = await import('node:crypto'); const { feedback } = await fixture(); const client = new GitHub('owner/repo', 'fixture'), report = verifiedReport(); const published = 'd'.repeat(40), branch = feedback.pr.head.ref; client.pull = async () => ({ ...feedback.pr, head: { ...feedback.pr.head, sha: published } }); const content = Buffer.from(report.changes[0]!.content); const blob = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); let tampered = false, badParent = false; client.request = async (path: string, method = 'GET'): Promise => { assert.equal(method, 'GET'); if (path === 'git/commits/' + published) return { message: 'fix\n\nRepoPilot-Run: ' + report.id, tree: { sha: 'after' }, parents: [{ sha: badParent ? 'other' : report.head }, { sha: report.base }] } as T; if (path === 'git/commits/' + report.head) return { tree: { sha: 'before' } } as T; return { truncated: false, tree: [{ path: 'src/a.ts', mode: '100644', type: 'blob', sha: path.includes('after') ? (tampered ? 'unexpected' : blob) : 'old' }] } as T; }; assert.equal((await client.recoverPull(report, 1, branch))?.head, published); tampered = true; assert.equal(await client.recoverPull(report, 1, branch), undefined); tampered = false; badParent = true; assert.equal(await client.recoverPull(report, 1, branch), undefined); }); test('maintenance reconciles an interrupted remote update without another repair or budget refund', async () => { const { d, state, feedback } = await fixture(); const report = verifiedReport(); await d.store.save(report); state.maintenance = { reportId: report.id, digest: 'old', consumed: [], outcome: 'running', pendingComments: ['review-7'] }; state.calls = 4; state.activeSince = new Date(Date.now() - 1000).toISOString(); feedback.headRun = report.id; d.github.recoverPull = async () => ({ url: state.pullRequestUrl!, head: feedback.pr.head.sha }); d.repository.prepare = async () => { throw new Error('Reconciliation must not execute a repair'); }; assert.equal((await maintainGoal(state.id, d)).status, 'recovered'); assert.equal(state.maintenance.head, feedback.pr.head.sha); assert.equal(state.maintenance.base, report.base); assert.deepEqual(state.maintenance.consumed, ['review-7']); assert.equal(state.calls, 4); assert.equal(state.activeSince, undefined); assert.equal((await d.store.read(report.id))?.status, 'published'); }); test('freshness changes during Git data construction prevent ref updates', async () => { const { feedback } = await fixture(); const client = new GitHub('owner/repo', 'fixture'); let calls = 0, refs = 0; client.feedback = async () => { calls++; return calls === 1 ? feedback : { ...feedback, comments: [{ id: 'new', author: 'maintainer', body: 'A newly arrived review' }] }; }; client.request = async (path: string): Promise => { if (path.startsWith('git/refs/')) refs++; if (path.startsWith('git/commits/')) return { tree: { sha: 'parent' } } as T; if (path.startsWith('git/trees/')) return { truncated: false, tree: [] } as T; return { sha: 'constructed' } as T; }; assert.equal(await client.updatePull(verifiedReport(), 1, feedback.pr.head.sha, feedback.pr.base.sha, feedbackDigest(feedback)), undefined); assert.equal(refs, 0); }); test('maintenance independently verifies base integration and retains generated tests before updating the PR', async () => { const { testCase } = await import('./helpers.js'); const { d, state, feedback } = await fixture(); d.config.publish = true; state.configHash = taskId(d.config); const nextBase = 'c'.repeat(40); feedback.pr.base = { ...feedback.pr.base, sha: nextBase }; d.repository.snapshot = async (_cache, sha) => new Map([['src/a.ts', 'GOOD'], ...(sha === nextBase ? [['src/b.ts', 'base addition'] as [string, string]] : [])]); d.runner = { run: async files => result('passed', [testCase(), ...(files.has('test/generated.test.js') ? [testCase('test/generated.test.js')] : [])]) }; let writes = 0; d.github.updatePull = async report => { writes++; assert.equal(report.status, 'verified'); assert.ok(report.changes.some(c => c.path === 'src/b.ts')); assert.ok(report.changes.some(c => c.path === 'test/generated.test.js')); assert.equal(report.tests.repaired?.cases.length, 2); return { url: state.pullRequestUrl!, head: 'd'.repeat(40) }; }; assert.equal((await maintainGoal(state.id, d)).status, 'updated'); assert.equal(writes, 1); assert.equal(state.maintenance?.base, nextBase); assert.equal(state.maintenance?.head, 'd'.repeat(40)); }); test('failed CI without independently reproduced failure cannot publish generated tests as a fix', async () => { const { testCase } = await import('./helpers.js'); const { d, state, feedback } = await fixture(); d.config.publish = true; state.configHash = taskId(d.config); feedback.checks[0]!.conclusion = 'failure'; d.runner = { run: async files => result('passed', [testCase(), ...(files.has('test/generated.test.js') ? [testCase('test/generated.test.js')] : [])]) }; assert.equal((await maintainGoal(state.id, d)).status, 'ci_not_reproduced'); assert.equal(state.maintenance?.outcome, 'failed'); }); test('maintenance retains failed patch fingerprints across rounds and settles actual over-budget usage', async () => { const { testCase, answer } = await import('./helpers.js'); const { d, state, feedback } = await fixture(); feedback.checks[0]!.conclusion = 'failure'; state.changes = [{ path: 'src/a.ts', content: 'BAD' }]; d.repository.snapshot = async (_cache, sha) => new Map([['src/a.ts', sha === feedback.pr.head.sha ? 'BAD' : 'GOOD']]); d.runner = { run: async files => { const status = files.get('src/a.ts') === 'GOOD' ? 'passed' : 'failed'; return result(status, [testCase(undefined, status), ...(files.has('test/generated.test.js') ? [testCase('test/generated.test.js', status)] : [])]); } }; let repairs = 0; d.agent!.repair = async () => { repairs++; return answer([{ path: 'src/a.ts', content: 'BAD2' }]); }; assert.equal((await maintainGoal(state.id, d)).status, 'needs_attention'); assert.ok(state.maintenance!.patches?.length); const previous = repairs; assert.equal((await maintainGoal(state.id, d)).status, 'needs_attention'); assert.equal(repairs - previous, 1); const report = await d.store.read(state.maintenance!.reportId); assert.match(report!.repairs[0]!.reason!, /Repeated patch/); const calls = state.calls, tokens = state.tokens; d.agent!.usage = () => ({ calls: d.config.agent.maxCalls + 1, tokens: d.config.agent.maxTokens + 17 }); d.repository.prepare = async () => { throw new Error('budget fixture'); }; await assert.rejects(maintainGoal(state.id, d), /budget fixture/); assert.equal(state.calls - calls, d.config.agent.maxCalls + 1); assert.equal(state.tokens - tokens, d.config.agent.maxTokens + 17); });