diff --git a/packages/factory-sdk/src/config/schema.test.ts b/packages/factory-sdk/src/config/schema.test.ts index cbf902eb..e06d73df 100644 --- a/packages/factory-sdk/src/config/schema.test.ts +++ b/packages/factory-sdk/src/config/schema.test.ts @@ -40,6 +40,7 @@ describe('FactoryConfigSchema', () => { expect(parsed.stateIds).toEqual(LINEAR_STATE_IDS) expect(parsed.safety).toEqual({ requireTitlePrefix: '[factory-e2e]', + requireLabel: 'factory', requireTeamKey: 'AR', }) expect(parsed.dryRun).toBe(false) diff --git a/packages/factory-sdk/src/config/schema.ts b/packages/factory-sdk/src/config/schema.ts index c83a232e..39b36335 100644 --- a/packages/factory-sdk/src/config/schema.ts +++ b/packages/factory-sdk/src/config/schema.ts @@ -55,6 +55,7 @@ export const FactoryConfigSchema = z.object({ }).default(LINEAR_STATE_IDS), safety: z.object({ requireTitlePrefix: z.string().min(1).default('[factory-e2e]'), + requireLabel: z.string().default('factory'), requireTeamKey: z.string().min(1).default('AR'), }).default({}), dryRun: z.boolean().default(false), diff --git a/packages/factory-sdk/src/orchestrator/factory.test.ts b/packages/factory-sdk/src/orchestrator/factory.test.ts index 134ac29e..09da3694 100644 --- a/packages/factory-sdk/src/orchestrator/factory.test.ts +++ b/packages/factory-sdk/src/orchestrator/factory.test.ts @@ -30,8 +30,9 @@ const ready = 'b9bec744-b60c-4745-8022-d90d6ab59ae3' const implementing = '39b9881d-1196-4c95-8b80-a20f0c7263f7' const done = '83ea5383-bfe9-425a-86ef-517b8190f09a' -type FactoryConfigOverrides = Omit, 'loop'> & { +type FactoryConfigOverrides = Omit, 'loop' | 'safety'> & { loop?: Partial + safety?: Partial } const config = (overrides: FactoryConfigOverrides = {}): FactoryConfig => FactoryConfigSchema.parse({ @@ -1343,6 +1344,38 @@ describe('FactoryLoop', () => { )).toBe(true) }) + it('accepts Linear issues by configured title prefix or factory label while still requiring the team', async () => { + const safety = { requireTitlePrefix: '[factory]', requireLabel: 'factory', requireTeamKey: 'AR' } + const issue = ( + title: string, + labels: unknown[], + teamKey = 'AR', + ) => ({ + title, + labels: labels.filter((label): label is string => typeof label === 'string'), + team: teamKey, + raw: { payload: { title, labels, team: { key: teamKey } } }, + }) + + expect(isInFactoryScope(issue('Implement label-only scope', [{ name: 'Factory' }]), safety)).toBe(true) + expect(isInFactoryScope(issue('[factory] Implement title-only scope', []), safety)).toBe(true) + expect(isInFactoryScope(issue('[factory] Implement both scope paths', ['factory']), safety)).toBe(true) + expect(isInFactoryScope(issue('Implement neither scope path', [{ name: 'pear' }]), safety)).toBe(false) + expect(isInFactoryScope(issue('Implement wrong team label-only scope', ['factory'], 'OTHER'), safety)).toBe(false) + }) + + it('disables the Linear label scope path when requireLabel is empty', async () => { + expect(isInFactoryScope( + { + title: 'Implement label-only scope', + labels: ['factory'], + team: 'AR', + raw: { payload: { title: 'Implement label-only scope', labels: [{ name: 'factory' }], team: { key: 'AR' } } }, + }, + { requireTitlePrefix: '[factory]', requireLabel: '', requireTeamKey: 'AR' }, + )).toBe(false) + }) + it('uses canonical issue state during startup backfill when a ready alias is stale', async () => { const mount = new FakeMountClient({ [issuePath(69)]: realIssueFile(69, done), @@ -2474,6 +2507,28 @@ describe('FactoryLoop', () => { expect(mount.writes).toEqual([]) }) + it('dispatches ready Linear issues selected by the factory label without a title prefix', async () => { + const path = issuePath(260) + const mount = new FakeMountClient({ + [path]: realIssueFile(260, ready, { + title: 'Accept factory label as scope marker', + labels: [{ name: 'factory' }], + }), + }) + const fleet = new FakeFleetClient() + const triage = new CountingTriage() + const factory = createFactory(config({ + safety: { requireTitlePrefix: '[factory]', requireTeamKey: 'AR' }, + }), { mount, fleet, triage }) + + const report = await factory.runOnce() + + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['AR-260']) + expect(report.skipped).toEqual([]) + expect(triage.count).toBe(1) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-260-impl', 'ar-260-review']) + }) + it('skips factory-marked draft issues that are not reconciled provider records', async () => { const draftPath = '/linear/issues/AR-E2ECANARY.json' const mount = new FakeMountClient({ @@ -2503,6 +2558,39 @@ describe('FactoryLoop', () => { expect(mount.writes).toEqual([]) }) + it('does not dispatch label-only factory draft issues that are not reconciled provider records', async () => { + const draftPath = issuePath(261) + const mount = new FakeMountClient({ + [draftPath]: { + provider: 'linear', + objectType: 'issue', + objectId: 'uuid-261', + payload: { + ...issuePayload(261), + title: 'Label-only draft should not dispatch', + labels: [{ name: 'factory' }], + url: undefined, + }, + }, + }) + const fleet = new FakeFleetClient() + const triage = new CountingTriage() + const factory = createFactory(config({ + safety: { requireTitlePrefix: '[factory]', requireTeamKey: 'AR' }, + }), { mount, fleet, triage }) + + const report = await factory.runOnce() + + expect(report.skipped).toContainEqual({ + issue: { uuid: 'uuid-261', key: 'AR-261', path: draftPath }, + reason: 'not reconciled real Linear issue', + }) + expect(report.triaged).toEqual([]) + expect(report.dispatched).toEqual([]) + expect(triage.count).toBe(0) + expect(fleet.spawns).toEqual([]) + }) + it('refuses explicit dispatch for factory-marked issues without a provider URL', async () => { const draft = { ...issueFile(24), @@ -2545,6 +2633,27 @@ describe('FactoryLoop', () => { expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-27-impl', 'ar-27-review']) }) + it('dispatches ready AR issues scoped only by the factory label', async () => { + const path = issuePath(28) + const labelOnlyIssue = { + ...issueFile(28), + payload: { + ...issuePayload(28), + title: 'Label-scoped factory issue', + labels: [{ name: 'factory' }], + }, + } + const mount = new FakeMountClient({ [path]: labelOnlyIssue }) + const fleet = new FakeFleetClient() + const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage() }) + + const report = await factory.runOnce() + + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['AR-28']) + expect(report.skipped).toEqual([]) + expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-28-impl', 'ar-28-review']) + }) + it('refuses explicit dispatch for issues outside factory-e2e scope before spawning', async () => { const unscopedIssue = { ...issueFile(22), @@ -3968,6 +4077,48 @@ describe('FactoryLoop', () => { ]) }) + it('does not treat a factory label as a synthetic probe marker', async () => { + const labelOnlyIssue = realIssueFile(19, ready, { + title: 'Label-scoped probe-shaped issue', + labels: [{ name: 'factory' }], + }) + const mount = new FakeMountClient({ + [issuePath(19)]: labelOnlyIssue, + '/github/repos/AgentWorkforce__pear/pulls/by-id/19.json': { + provider: 'github', + objectType: 'pull_request', + objectId: '19', + payload: { + number: 19, + title: '[factory-e2e] AR-19 probe', + body: 'Synthetic probe for AR-19', + head_ref: 'factory-e2e/ar-19-probe', + }, + }, + }) + const fleet = new FakeFleetClient() + const closeInputs: Array> = [] + const factory = createFactory(config(), { + mount, + fleet, + triage: new StaticTriage(), + probeCloser: async (input) => { + closeInputs.push(input) + return { repo: input.repo, prNumber: input.prNumber, state: 'CLOSED' } + }, + }) + + await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(19), labelOnlyIssue))) + fleet.emitAgentExit('ar-19-impl', 'issue-done') + await flush() + + expect(closeInputs).toEqual([]) + expect(fleet.releases).toEqual([ + { name: 'ar-19-impl', reason: 'issue-done' }, + { name: 'ar-19-review', reason: 'issue-done' }, + ]) + }) + it('completion releases and terminates tracked pair process trees', async () => { const mount = new FakeMountClient({ [issuePath(64)]: issueFile(64) }) const fleet = new CapturedPidFleetClient([ @@ -4768,9 +4919,13 @@ describe('FactoryLoop', () => { } }) - it('does not close PRs for non-synthetic issues', async () => { + it('does not close PRs for label-only non-synthetic issues', async () => { + const issue = realIssueFile(230, ready, { + title: 'Real product fix', + labels: [{ name: 'factory' }], + }) const mount = new FakeMountClient({ - [issuePath(230)]: realMergeIssueFile(230), + [issuePath(230)]: issue, '/github/repos/AgentWorkforce__pear/pulls/by-id/230.json': { provider: 'github', objectType: 'pull_request', @@ -4786,7 +4941,7 @@ describe('FactoryLoop', () => { const fleet = new FakeFleetClient() const closeInputs: unknown[] = [] const factory = createFactory(config({ - safety: { requireTitlePrefix: 'Real', requireTeamKey: 'AR' }, + safety: { requireTitlePrefix: '[factory]', requireTeamKey: 'AR' }, }), { mount, fleet, @@ -4797,7 +4952,7 @@ describe('FactoryLoop', () => { }, }) - await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(230), realMergeIssueFile(230)))) + await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(230), issue))) fleet.emitAgentExit('ar-230-impl', 'issue-done') await flush() diff --git a/packages/factory-sdk/src/safety/factory-scope.test.ts b/packages/factory-sdk/src/safety/factory-scope.test.ts new file mode 100644 index 00000000..c982d049 --- /dev/null +++ b/packages/factory-sdk/src/safety/factory-scope.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { isInFactoryScope } from './factory-scope' + +const issue = (overrides: { + title?: string + team?: string + labels?: string[] + payloadLabels?: unknown +} = {}) => ({ + title: overrides.title ?? 'Plain Linear issue', + team: overrides.team ?? 'AR', + labels: overrides.labels ?? [], + raw: { + payload: { + title: overrides.title ?? 'Plain Linear issue', + team: { key: overrides.team ?? 'AR' }, + labels: overrides.payloadLabels ?? overrides.labels ?? [], + }, + }, +}) + +const safety = { + requireTitlePrefix: '[factory]', + requireLabel: 'factory', + requireTeamKey: 'AR', +} + +describe('isInFactoryScope', () => { + it('accepts a Linear issue with only the configured factory label', () => { + expect(isInFactoryScope(issue({ + labels: ['Factory'], + payloadLabels: [{ name: 'Factory' }], + }), safety)).toBe(true) + }) + + it('accepts a Linear issue with only the configured title prefix', () => { + expect(isInFactoryScope(issue({ + title: '[factory] Title-scoped issue', + }), safety)).toBe(true) + }) + + it('accepts a Linear issue with both the configured title prefix and label', () => { + expect(isInFactoryScope(issue({ + title: '[factory] Fully scoped issue', + labels: ['factory'], + }), safety)).toBe(true) + }) + + it('rejects a Linear issue with neither the configured title prefix nor label', () => { + expect(isInFactoryScope(issue(), safety)).toBe(false) + }) + + it('rejects a Linear issue with the factory label on the wrong team', () => { + expect(isInFactoryScope(issue({ + team: 'ENG', + labels: ['factory'], + }), safety)).toBe(false) + }) + + it('treats an empty configured label as title-only scope', () => { + expect(isInFactoryScope(issue({ + labels: ['factory'], + }), { + ...safety, + requireLabel: '', + })).toBe(false) + }) + + it('matches raw Linear connection label shapes case-insensitively', () => { + expect(isInFactoryScope(issue({ + payloadLabels: { + edges: [{ node: { name: 'FACTORY' } }], + }, + }), safety)).toBe(true) + }) +}) diff --git a/packages/factory-sdk/src/safety/factory-scope.ts b/packages/factory-sdk/src/safety/factory-scope.ts index dc71371f..7ef8c3f6 100644 --- a/packages/factory-sdk/src/safety/factory-scope.ts +++ b/packages/factory-sdk/src/safety/factory-scope.ts @@ -3,22 +3,24 @@ import type { LinearIssue } from '../types' export interface FactoryScopeSafety { requireTitlePrefix?: string + requireLabel?: string requireTeamKey?: string } export interface NormalizedFactoryScopeSafety { titlePrefix: string + label?: string teamKey: string } export function isInFactoryScope( - issue: Pick, + issue: Pick & Partial>, safety: FactoryScopeSafety = {}, ): boolean { const expected = normalizeSafety(safety) const payload = wrappedPayload(issue.raw) const title = stringValue(payload.title) ?? issue.title - if (!titleHasAcceptedFactoryMarker(title, expected.titlePrefix, isGithubMirrorPayload(payload))) { + if (!hasAcceptedFactoryMarker(issue, payload, title, expected)) { return false } @@ -31,7 +33,7 @@ export function isInFactoryScope( } export function assertInFactoryScope( - issue: Pick, + issue: Pick & Partial>, safety: FactoryScopeSafety = {}, context = issue.key, ): void { @@ -46,14 +48,16 @@ export function factoryScopeSafety(config: Pick): Norma } function factoryScopeFailureReason( - issue: Pick, + issue: Pick & Partial>, safety: FactoryScopeSafety = {}, ): string | undefined { const expected = normalizeSafety(safety) const payload = wrappedPayload(issue.raw) const title = stringValue(payload.title) ?? issue.title - if (!titleHasAcceptedFactoryMarker(title, expected.titlePrefix, isGithubMirrorPayload(payload))) { - return `title must start with ${expected.titlePrefix} boundary` + if (!hasAcceptedFactoryMarker(issue, payload, title, expected)) { + return expected.label + ? `title must start with ${expected.titlePrefix} boundary or labels must include ${expected.label}` + : `title must start with ${expected.titlePrefix} boundary` } const team = asRecord(payload.team) @@ -67,10 +71,67 @@ function factoryScopeFailureReason( return undefined } -const normalizeSafety = (safety: FactoryScopeSafety = {}): NormalizedFactoryScopeSafety => ({ - titlePrefix: safety.requireTitlePrefix || '[factory-e2e]', - teamKey: safety.requireTeamKey || 'AR', -}) +const normalizeSafety = (safety: FactoryScopeSafety = {}): NormalizedFactoryScopeSafety => { + const label = normalizeRequiredLabel(safety.requireLabel) + return { + titlePrefix: safety.requireTitlePrefix || '[factory-e2e]', + ...(label ? { label } : {}), + teamKey: safety.requireTeamKey || 'AR', + } +} + +const normalizeRequiredLabel = (label: string | undefined): string | undefined => { + if (label === undefined) return DEFAULT_FACTORY_LABEL + const normalized = label.trim().toLowerCase() + return normalized || undefined +} + +const hasAcceptedFactoryMarker = ( + issue: Partial>, + payload: Record, + title: string, + expected: NormalizedFactoryScopeSafety, +): boolean => + titleHasAcceptedFactoryMarker(title, expected.titlePrefix, isGithubMirrorPayload(payload)) || + payloadHasFactoryLabel(issue, payload, expected.label) + +const payloadHasFactoryLabel = ( + issue: Partial>, + payload: Record, + expectedLabel: string | undefined, +): boolean => { + if (!expectedLabel) return false + const labels = [ + ...issueLabels(issue.labels), + ...payloadLabels(payload.labels), + ] + return labels.some((label) => label.trim().toLowerCase() === expectedLabel) +} + +const issueLabels = (labels: unknown): string[] => + Array.isArray(labels) ? labels.filter((label): label is string => typeof label === 'string') : [] + +const payloadLabels = (labels: unknown): string[] => { + if (Array.isArray(labels)) { + return labels.map(labelName).filter((label): label is string => Boolean(label)) + } + const record = asRecord(labels) + if (Array.isArray(record?.nodes)) { + return record.nodes.map(labelName).filter((label): label is string => Boolean(label)) + } + if (Array.isArray(record?.edges)) { + return record.edges + .map((edge) => labelName(asRecord(edge)?.node)) + .filter((label): label is string => Boolean(label)) + } + return [] +} + +const labelName = (value: unknown): string | undefined => { + if (typeof value === 'string') return value + const record = asRecord(value) + return stringValue(record?.name) +} const titleHasFactoryMarker = (title: string, marker: string): boolean => title === marker || title.startsWith(`${marker} `) @@ -92,6 +153,7 @@ const isGithubMirrorPayload = (payload: Record): boolean => stringValue(asRecord(payload.source)?.provider)?.toLowerCase() === 'github' const GITHUB_MIRROR_TITLE_PREFIX = '[factory]' +const DEFAULT_FACTORY_LABEL = 'factory' const wrappedPayload = (value: unknown): Record => { const record = asRecord(value) diff --git a/packages/factory-sdk/src/writeback/linear.ts b/packages/factory-sdk/src/writeback/linear.ts index d5c421e8..87d649a4 100644 --- a/packages/factory-sdk/src/writeback/linear.ts +++ b/packages/factory-sdk/src/writeback/linear.ts @@ -18,6 +18,7 @@ export interface MountLinearWritebackConfig { stateIds?: LinearStateIds safety?: { requireTitlePrefix?: string + requireLabel?: string requireTeamKey?: string } logger?: Pick @@ -51,12 +52,15 @@ const safetyFromConfig = (configOrStateIds?: LinearStateIds | MountLinearWriteba requireTitlePrefix: typeof safety.requireTitlePrefix === 'string' && safety.requireTitlePrefix ? safety.requireTitlePrefix : '[factory-e2e]', + requireLabel: typeof safety.requireLabel === 'string' + ? safety.requireLabel + : 'factory', requireTeamKey: typeof safety.requireTeamKey === 'string' && safety.requireTeamKey ? safety.requireTeamKey : 'AR', } } - return { requireTitlePrefix: '[factory-e2e]', requireTeamKey: 'AR' } + return { requireTitlePrefix: '[factory-e2e]', requireLabel: 'factory', requireTeamKey: 'AR' } } const payloadInFactoryScope = (