diff --git a/bin/factory.mjs b/bin/factory.mjs old mode 100644 new mode 100755 diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index c1c1fd3..64d60e7 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -53,6 +53,7 @@ describe('FactoryConfigSchema', () => { humanReview: 'In Human Review', }, statesByTeam: {}, + teamIds: {}, }) expect(parsed.safety).toEqual({ requireTitlePrefix: '[factory-e2e]', @@ -113,6 +114,7 @@ describe('FactoryConfigSchema', () => { repos: { byLabel: { pear: 'AgentWorkforce/pear' } }, linear: { states: { readyForAgent: 'Ready for Agent', done: 'Done' }, + teamIds: { AR: 'team-ar' }, statesByTeam: { ENG: { readyForAgent: 'To Do', done: 'Shipped' }, }, @@ -120,6 +122,7 @@ describe('FactoryConfigSchema', () => { }) expect(parsed.linear.states.readyForAgent).toBe('Ready for Agent') + expect(parsed.linear.teamIds.AR).toBe('team-ar') expect(parsed.linear.statesByTeam.ENG).toEqual({ readyForAgent: 'To Do', done: 'Shipped' }) expect(parsed.stateIds).toEqual({}) }) diff --git a/src/config/schema.ts b/src/config/schema.ts index 55d800f..fe18dab 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -113,7 +113,8 @@ const DEFAULT_LINEAR_STATE_NAMES = { const linearSchema = z.object({ states: linearRoleNamesSchema.default(DEFAULT_LINEAR_STATE_NAMES), statesByTeam: z.record(z.string(), linearRoleNamesSchema).default({}), -}).default({ states: DEFAULT_LINEAR_STATE_NAMES, statesByTeam: {} }) + teamIds: z.record(z.string(), z.string()).default({}), +}).default({ states: DEFAULT_LINEAR_STATE_NAMES, statesByTeam: {}, teamIds: {} }) const stateIdsSchema = z.object({ readyForAgent: z.string().optional(), diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index f4b0c09..7643c40 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -1268,6 +1268,7 @@ describe('FactoryLoop', () => { default: 'AgentWorkforce/relayfile-adapters', }, safety: { requireTitlePrefix: '[factory]', requireTeamKey: 'AR' }, + linear: { teamIds: { AR: 'team-ar' } }, }), { mount, fleet: new FakeFleetClient(), triage: new StaticTriage() }) await factory.runOnce({ dryRun: false }) @@ -1293,6 +1294,64 @@ describe('FactoryLoop', () => { expect(factory.status().counters.githubIssueMirrorsCreated).toBe(1) }) + it('mirrors compact GitHub issues when the repo root listing is shallow', async () => { + const ghPath = githubIssueCompactPath('AgentWorkforce', 'relayfile-adapters', 224) + class ShallowGithubRootMount extends FakeMountClient { + readonly listTreePrefixes: string[] = [] + + override async listTree(prefix: string): Promise { + this.listTreePrefixes.push(prefix) + if (prefix === '/github/repos') { + return [ + '/github/repos/AgentWorkforce/relayfile-adapters', + '/github/repos/AgentWorkforce__relayfile-adapters', + '/github/repos/AgentWorkforce__relayfile-adapters/issues', + ] + } + return super.listTree(prefix) + } + } + const mount = new ShallowGithubRootMount({ + [ghPath]: githubIssueFile(224, { + owner: 'AgentWorkforce', + repo: 'relayfile-adapters', + title: 'Export shared mount-path parser', + body: 'The root listing is shallow in the cloud mount.', + labels: ['factory'], + }), + }) + const factory = createFactory(config({ + repos: { + byLabel: { 'relayfile-adapters': 'AgentWorkforce/relayfile-adapters' }, + clonePaths: { 'AgentWorkforce/relayfile-adapters': '/work/relayfile-adapters' }, + default: 'AgentWorkforce/relayfile-adapters', + }, + safety: { requireTitlePrefix: '[factory]', requireTeamKey: 'AR' }, + linear: { teamIds: { AR: 'team-ar' } }, + }), { mount, fleet: new FakeFleetClient(), triage: new StaticTriage() }) + + await factory.runOnce({ dryRun: false }) + + expect(mount.listTreePrefixes).toEqual(expect.arrayContaining([ + '/github/repos', + '/github/repos/AgentWorkforce__relayfile-adapters/issues/by-id', + ])) + expect(mount.writes).toHaveLength(1) + expect(mount.writes[0]?.content).toEqual(expect.objectContaining({ + title: '[factory] Export shared mount-path parser', + teamId: 'team-ar', + labels: [{ name: 'relayfile-adapters' }], + source: expect.objectContaining({ + provider: 'github', + owner: 'AgentWorkforce', + repo: 'relayfile-adapters', + number: 224, + path: ghPath, + }), + })) + expect(factory.status().counters.githubIssueMirrorsCreated).toBe(1) + }) + it('mirrors GitHub issues with owner, repo, and number derived from nested relayfile paths', async () => { const ghPath = '/github/repos/AgentWorkforce/pear/issues/1116__route-github-factory-issues/meta.json' const mount = new FakeMountClient({ @@ -3781,7 +3840,13 @@ describe('FactoryLoop', () => { await factory.start({ mode: 'live', liveSubscription: { transport: 'subscribe' } }) - expect(mount.listTreePrefixes).toEqual(['/github/repos', '/linear/issues', '/linear/issues/by-state/ready-for-agent/', '.integrations/discovery']) + expect(mount.listTreePrefixes).toEqual([ + '/github/repos', + '/github/repos/AgentWorkforce__pear/issues/by-id', + '/linear/issues', + '/linear/issues/by-state/ready-for-agent/', + '.integrations/discovery', + ]) expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-40-impl-pear', 'ar-40-review']) expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['AR-40']) expect(factory.status().counters.liveHighWatermarkUnavailable).toBe(1) @@ -4398,6 +4463,41 @@ describe('FactoryLoop', () => { expect(comments.some((c) => c.includes('No Linear labels were present'))).toBe(false) }) + it('routes a label-less GitHub mirror from its source URL before repos.default', async () => { + const mirrorIssue = realIssueFile(724, ready, { + labels: [], + title: '[factory] GitHub mirror without synced labels', + description: 'Issue body\n\nSource: https://github.com/AgentWorkforce/relayfile-adapters/issues/224', + }) + const mount = new FakeMountClient({ [issuePath(724)]: mirrorIssue }) + const fleet = new FakeFleetClient() + const factory = createFactory(config({ + repos: { + byLabel: { 'relayfile-adapters': 'AgentWorkforce/relayfile-adapters' }, + clonePaths: { + 'AgentWorkforce/factory': '/work/factory', + 'AgentWorkforce/relayfile-adapters': '/work/relayfile-adapters', + }, + default: 'AgentWorkforce/factory', + }, + safety: { requireTitlePrefix: '[factory]', requireTeamKey: 'AR' }, + }), { + mount, + fleet, + triage: new StaticTriage(), + linear: stateOnlyLinear(mount), + }) + + const result = await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(724), mirrorIssue))) + + expect(result.agents.map((a) => a.role)).toEqual(['implementer', 'reviewer']) + expect(fleet.spawns.map((s) => s.cwd)).toEqual([ + '/work/relayfile-adapters', + '/work/relayfile-adapters', + ]) + expect(fleet.spawns.map((s) => s.name)).toEqual(['ar-724-impl-relayfile-adapters', 'ar-724-review']) + }) + it('fails dispatch loudly when labels do not map through repos.byLabel', async () => { const unmappedIssue = realIssueFile(723, ready, { labels: [{ name: 'unknown' }] }) const mount = new FakeMountClient({ [issuePath(723)]: unmappedIssue }) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 57b467b..f909401 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -1629,24 +1629,26 @@ export class FactoryLoop implements Factory { async #githubIssuePaths(): Promise { try { - const paths = await this.#listRelayfileTree(GITHUB_ISSUE_ROOT, 'GitHub issue ingestion') - const issuePaths: string[] = [] - for (const path of paths) { - if (githubIssuePathParts(path) !== undefined) { - issuePaths.push(path) - } else if (githubIssueDirectoryPathParts(path) !== undefined) { - // listTree returns the issue directory entry alongside its - // meta.json file; githubIssuePathParts() already collected the - // file, so skip the directory to avoid reading the same issue - // twice in one backfill pass. Directory paths are only meaningful - // for live change events, not the tree scan. - continue - } else if (isGithubIssueTreePath(path)) { - this.#increment('githubIssuesIgnoredByPathRegex') - this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path }) + const issuePaths = new Set() + for (const root of githubIssueScanRoots(this.#config)) { + const paths = await this.#listRelayfileTree(root, 'GitHub issue ingestion') + for (const path of paths) { + if (githubIssuePathParts(path) !== undefined) { + issuePaths.add(path) + } else if (githubIssueDirectoryPathParts(path) !== undefined) { + // listTree returns the issue directory entry alongside its + // meta.json file; githubIssuePathParts() already collected the + // file, so skip the directory to avoid reading the same issue + // twice in one backfill pass. Directory paths are only meaningful + // for live change events, not the tree scan. + continue + } else if (isGithubIssueTreePath(path)) { + this.#increment('githubIssuesIgnoredByPathRegex') + this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path }) + } } } - return issuePaths.sort() + return [...issuePaths].sort() } catch (error) { this.#increment('githubIssueListFailures') this.#logger.warn?.('[factory] failed to list GitHub issue source tree', error) @@ -3992,6 +3994,22 @@ function labelDerivedDispatchDecision( const routesByLabel = labelRoutesForIssue(liveIssue, config) if (routesByLabel.labels.length === 0) { + const githubMirrorRoute = githubMirrorRouteForIssue(liveIssue, config) + if (githubMirrorRoute) { + const implementer = routeImplementerSpec(liveIssue, config, githubMirrorRoute.slug, githubMirrorRoute.route) + return { + ok: true, + decision: { + ...decision, + routes: [githubMirrorRoute.route], + scope: 'single', + implementers: [implementer], + workflow: undefined, + reviewer: routeReviewerSpec(liveIssue, config, githubMirrorRoute.route, decision.reviewer), + }, + } + } + // No repo labels — which is also what a label-less sync produces // (relayfile-adapters#205, labels dropped from the synced record). Fall back // to the configured default repo (consistent with triage, which already @@ -4076,6 +4094,55 @@ function labelDerivedDispatchDecision( } } +function githubMirrorRouteForIssue( + issue: LinearIssue, + config: FactoryConfig, +): { slug: string; route: TriageDecision['routes'][number] } | undefined { + const repo = githubMirrorRepoForIssue(issue) + if (!repo) { + return undefined + } + const entry = findLabelRoute(config.repos.byLabel, repo) + ?? findLabelRoute(config.repos.byLabel, repo.split('/').at(-1) ?? repo) + if (!entry) { + return undefined + } + return { + slug: entry.label, + route: { + repo: entry.repo, + clonePath: config.repos.clonePaths[entry.repo], + rationale: `GitHub mirror source ${repo} routes to ${entry.repo}.`, + }, + } +} + +function githubMirrorRepoForIssue(issue: LinearIssue): string | undefined { + const payload = wrappedPayload(issue.raw) + const source = asRecord(payload.source) + if (stringValue(source?.provider)?.toLowerCase() === 'github') { + const owner = stringValue(source?.owner) + const repo = stringValue(source?.repo) + if (owner && repo) { + return `${owner}/${repo}` + } + const urlRepo = githubRepoFromUrl(stringValue(source?.url)) + if (urlRepo) { + return urlRepo + } + } + const sourceUrlLine = issue.description + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line.startsWith(GITHUB_MIRROR_SOURCE_PREFIX)) + return githubRepoFromUrl(sourceUrlLine?.slice(GITHUB_MIRROR_SOURCE_PREFIX.length)) +} + +function githubRepoFromUrl(url: string | undefined): string | undefined { + const match = url?.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[/?#].*)?$/iu) + return match?.[1] && match[2] ? `${match[1]}/${match[2]}` : undefined +} + function labelRoutesForIssue( issue: LinearIssue, config: FactoryConfig, @@ -4363,22 +4430,26 @@ const githubIssueMirrorPayload = ( repoLabel: string, config: FactoryConfig, readyForAgentStateId: string, -): Record => ({ - id: githubIssueMirrorId(issue), - title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(), - description: githubIssueMirrorDescription(issue), - stateId: readyForAgentStateId, - labels: [{ name: repoLabel }], - team: { key: config.safety.requireTeamKey }, - source: { - provider: 'github', - owner: issue.owner, - repo: issue.repoName, - number: issue.number, - url: issue.url, - path: issue.path, - }, -}) +): Record => { + const teamId = config.linear.teamIds[config.safety.requireTeamKey] + return { + id: githubIssueMirrorId(issue), + title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(), + description: githubIssueMirrorDescription(issue), + stateId: readyForAgentStateId, + labels: [{ name: repoLabel }], + ...(teamId ? { teamId } : {}), + team: { key: config.safety.requireTeamKey, ...(teamId ? { id: teamId } : {}) }, + source: { + provider: 'github', + owner: issue.owner, + repo: issue.repoName, + number: issue.number, + url: issue.url, + path: issue.path, + }, + } +} const githubIssueMirrorDescription = (issue: GithubIssueSource): string => { const body = issue.body.trim() @@ -4509,6 +4580,28 @@ const reposFromConfig = (config: FactoryConfig): string[] => { return [...repos] } +const githubIssueScanRoots = (config: FactoryConfig): string[] => { + const roots = new Set([GITHUB_ISSUE_ROOT]) + for (const repo of reposFromConfig(config)) { + const parts = githubRepoParts(repo) + if (!parts) continue + roots.add(`/github/repos/${parts.owner}__${parts.repo}/issues/by-id`) + } + return [...roots] +} + +const githubRepoParts = (repo: string): { owner: string; repo: string } | undefined => { + const split = repo.match(/^([^/]+)\/([^/]+)$/u) + if (split) { + return { owner: split[1]!, repo: split[2]! } + } + const compact = repo.match(/^([^/]+)__([^/]+)$/u) + if (compact) { + return { owner: compact[1]!, repo: compact[2]! } + } + return undefined +} + const githubPullRoot = (repo: string): string => { const [owner, name] = repo.split('/') return owner && name ? `/github/repos/${owner}__${name}/pulls/by-id/` : `/github/repos/${repo}/pulls/by-id/`