diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index f5c34b1..c74a5ec 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import type { CloseProbePrInput, Factory, FactoryPorts, createFactory } from '../index' +import { stateResolutionFromIds } from '../index' import { FileStateStore } from '../state/file-state-store' import { FakeFleetClient, FakeMountClient } from '../testing' import { installFactoryStopSignalHandlers, parseFleetCommand, parseGlobalOptions, resolveBrokerConnectionPath, runFleetCli } from './fleet' @@ -552,9 +553,24 @@ describe('fleet CLI runtime', () => { it('auto-detects GitHub-only workspaces without resolving Linear states', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-only-')) try { - const configPath = await writeConfig(root) + const configPath = await writeConfig(root, { + stateIds: undefined, + repos: { + org: 'AgentWorkforce', + names: ['pear'], + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }) const githubPath = '/github/repos/AgentWorkforce/pear/issues/by-id/48.json' - const mount = new FakeMountClient({ + const mount = new (class extends FakeMountClient { + override async ensureSubRoot(prefix: string): Promise<'ready' | 'absent'> { + if (prefix === '/linear/issues') { + throw new Error('Linear sub-root probe must not run for a GitHub-oriented config') + } + return super.ensureSubRoot(prefix) + } + })({ [githubPath]: { provider: 'github', objectType: 'issue', @@ -570,8 +586,8 @@ describe('fleet CLI runtime', () => { }, }, }) - mount.setSubRoot('/linear/issues', 'absent') const output = buffer() + const errors = buffer() const code = await runFleetCli([ 'run-once', @@ -585,10 +601,10 @@ describe('fleet CLI runtime', () => { throw new Error('Linear state resolution must not run') }, stdout: output, - stderr: buffer(), + stderr: errors, }) - expect(code).toBe(0) + expect(code, errors.text()).toBe(0) expect(JSON.parse(output.text())).toMatchObject({ pulled: [{ key: '48', path: githubPath }], dispatched: [{ issue: { key: '48' }, dryRun: true }], @@ -622,6 +638,77 @@ describe('fleet CLI runtime', () => { } }) + it('keeps legacy Linear configs with repository routing on the Linear source', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-linear-with-repos-')) + try { + const configPath = await writeConfig(root, { + repos: { + org: 'AgentWorkforce', + names: ['pear'], + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }) + const githubPath = '/github/repos/AgentWorkforce__pear/issues/by-id/77.json' + const mount = new FakeMountClient({ + [issuePath]: issueFile, + [githubPath]: githubIssueFile('pear', 77), + }) + const output = buffer() + + const code = await runFleetCli(['dispatch', 'AR-77', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount, + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toMatchObject({ issue: { key: 'AR-77', path: issuePath } }) + expect(mount.reads).not.toContain(githubPath) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('honors an explicit Linear source for an otherwise GitHub-oriented config', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-explicit-linear-')) + try { + const configPath = await writeConfig(root, { + issueSource: 'linear', + stateIds: undefined, + repos: { + org: 'AgentWorkforce', + names: ['pear'], + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }) + const mount = new (class extends FakeMountClient { + override async ensureSubRoot(): Promise<'ready' | 'absent'> { + throw new Error('explicit issueSource must not probe providers') + } + })({ + [issuePath]: issueFile, + '/github/repos/AgentWorkforce__pear/issues/by-id/77.json': githubIssueFile('pear', 77), + }) + const output = buffer() + + const code = await runFleetCli(['dispatch', 'AR-77', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount, + resolveStates: async () => stateResolutionFromIds(TEST_STATE_IDS), + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toMatchObject({ issue: { key: 'AR-77', path: issuePath } }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('rejects an ambiguous bare GitHub issue number across configured repositories', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-ambiguous-')) try { @@ -653,6 +740,28 @@ describe('fleet CLI runtime', () => { } }) + it('rejects non-positive GitHub issue numbers with source context', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-invalid-number-')) + try { + const configPath = await writeConfig(root, { issueSource: 'github' }) + const errors = buffer() + + const code = await runFleetCli(['dispatch', '0', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient(), + stdout: buffer(), + stderr: errors, + }) + + expect(code).toBe(1) + expect(errors.text()).toContain( + 'Unable to resolve GitHub issue 0 (issueSource=github): expected a positive issue number', + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('uses repos.default to disambiguate a bare GitHub issue number', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-default-')) try { @@ -665,7 +774,11 @@ describe('fleet CLI runtime', () => { }, }) const cloudPath = '/github/repos/AgentWorkforce/cloud/issues/by-id/48.json' - const mount = new FakeMountClient({ + const mount = new (class extends FakeMountClient { + override async listTree(): Promise { + throw new Error('direct GitHub issue resolution must not list the mount') + } + })({ '/github/repos/AgentWorkforce/pear/issues/by-id/48.json': githubIssueFile('pear'), [cloudPath]: githubIssueFile('cloud'), }) @@ -685,6 +798,141 @@ describe('fleet CLI runtime', () => { } }) + it('resolves a case-insensitive bare repos.default through repos.byLabel without listing the mount', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-bare-default-')) + try { + const configPath = await writeConfig(root, { + issueSource: 'github', + repos: { + byLabel: { pear: 'AgentWorkforce/pear' }, + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'PEAR', + }, + }) + const compactPath = '/github/repos/AgentWorkforce__pear/issues/by-id/48.json' + const mount = new (class extends FakeMountClient { + override async listTree(): Promise { + throw new Error('direct GitHub issue resolution must not list the mount') + } + })({ [compactPath]: githubIssueFile('pear') }) + const output = buffer() + + const code = await runFleetCli(['dispatch', '48', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount, + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toMatchObject({ issue: { key: '48', path: compactPath } }) + expect(mount.reads).toContain(compactPath) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it.each([ + '/github/repos/AgentWorkforce__pear/issues/48__scoped-fallback/meta.json', + '/github/repos/AgentWorkforce/pear/issues/48__scoped-fallback/meta.json', + ])('falls back to compact and nested repo-scoped issue roots for %s', async (issuePath) => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-scoped-fallback-')) + try { + const configPath = await writeConfig(root, { + issueSource: 'github', + repos: { + byLabel: { pear: 'AgentWorkforce/pear' }, + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }) + const listed: string[] = [] + const mount = new (class extends FakeMountClient { + override async listTree(prefix: string): Promise { + listed.push(prefix) + if (prefix === '/github/repos') throw new Error('global GitHub tree scan is forbidden') + return super.listTree(prefix) + } + })({ [issuePath]: githubIssueFile('pear') }) + const output = buffer() + + const code = await runFleetCli(['dispatch', '48', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount, + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toMatchObject({ issue: { key: '48', path: issuePath } }) + expect(listed.sort()).toEqual([ + '/github/repos/AgentWorkforce/pear/issues', + '/github/repos/AgentWorkforce__pear/issues', + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not hide a provider failure behind GitHub direct-read fallback', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-provider-error-')) + try { + const configPath = await writeConfig(root, { + issueSource: 'github', + repos: { + byLabel: { pear: 'AgentWorkforce/pear' }, + clonePaths: { 'AgentWorkforce/pear': '/work/pear' }, + default: 'AgentWorkforce/pear', + }, + }) + const mount = new (class extends FakeMountClient { + override async readFile(path: string): Promise<{ content: unknown; revision?: string }> { + if (path.endsWith('/48/meta.json')) { + throw Object.assign(new Error('GitHub connection unauthorized'), { status: 401 }) + } + return super.readFile(path) + } + })() + const errors = buffer() + + const code = await runFleetCli(['dispatch', '48', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount, + stdout: buffer(), + stderr: errors, + }) + + expect(code).toBe(1) + expect(errors.text()).toContain('Unable to read GitHub issue candidate') + expect(errors.text()).toContain('GitHub connection unauthorized') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('names an auto-detected Linear source and suggests the GitHub override on resolution failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-linear-source-error-')) + try { + const configPath = await writeConfig(root) + const errors = buffer() + + const code = await runFleetCli(['dispatch', '37', '--dry-run', '--config', configPath], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient(), + stdout: buffer(), + stderr: errors, + }) + + expect(code).toBe(1) + expect(errors.text()).toContain( + 'Unable to resolve issue 37 in Linear (issueSource=linear, auto-detected): found 0 matches', + ) + expect(errors.text()).toContain("set issueSource: 'github' if these are GitHub issues") + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('deduplicates alternate mounted paths for the same GitHub repository', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-dedupe-')) try { diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 44badb9..e62eaf7 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -88,6 +88,8 @@ interface LoadedConfig { fixtureFiles?: Record } +const autoDetectedIssueSources = new WeakSet() + type ParsedCommand = | { kind: 'spawn'; input: { capability: Capability; name?: string; node?: 'self' | string; task?: string; model?: string; sessionRef?: string; cwd?: string } } | { kind: 'roster' } @@ -860,16 +862,39 @@ async function resolveStatesForIssueSource( return stateResolutionFromIds(config.stateIds, config.linear.states) } if (!config.issueSource) { - const linearReady = await mount.ensureSubRoot('/linear/issues', { timeoutMs: 90_000 }) - if (linearReady !== 'ready') { + autoDetectedIssueSources.add(config) + if (shouldAutoDetectGithubSource(config)) { config.issueSource = 'github' return stateResolutionFromIds(config.stateIds, config.linear.states) } - config.issueSource = 'linear' + const linearReady = await mount.ensureSubRoot('/linear/issues', { timeoutMs: 90_000 }) + config.issueSource = linearReady === 'ready' ? 'linear' : 'github' + if (config.issueSource === 'github') { + return stateResolutionFromIds(config.stateIds, config.linear.states) + } } return (resolveStates ?? defaultResolveStates)(mount, config) } +function shouldAutoDetectGithubSource(config: FactoryConfig): boolean { + if (!config.repos.org || configuredGithubIssueRepos(config).length === 0) return false + return Object.keys(config.stateIds).length === 0 && + hasDefaultLinearStateNames(config.linear.states) && + Object.keys(config.linear.statesByTeam).length === 0 && + Object.keys(config.linear.teamIds).length === 0 && + config.subscription.teams.length === 0 && + config.subscription.projects.length === 0 && + config.subscription.assignees.length === 0 +} + +function hasDefaultLinearStateNames(states: FactoryConfig['linear']['states']): boolean { + return states.readyForAgent === 'Ready for Agent' && + states.agentImplementing === 'Agent Implementing' && + states.inPlanning === 'In Planning' && + states.done === 'Done' && + states.humanReview === 'In Human Review' +} + async function buildMount(loaded: LoadedConfig, deps: FleetCliDeps): Promise { if (deps.mount) return deps.mount if (hasExplicitFixtureFiles(loaded)) return new FakeMountClient(loaded.fixtureFiles) @@ -955,19 +980,36 @@ async function findIssuePath(mount: MountClient, key: string, config: FactoryCon if (config.issueSource === 'github') { const number = Number(key.replace(/^#/, '')) if (!Number.isInteger(number) || number <= 0) { - throw new Error(`Unable to resolve GitHub issue ${key}: expected a positive issue number`) + throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number`) + } + const configuredRepos = configuredGithubIssueRepos(config) + if (configuredRepos.length === 0 && hasConfiguredGithubIssueRoutes(config)) { + throw new Error( + `${githubIssueResolutionError(config, key)}: configured repository routes do not resolve to owner/repo; ` + + 'set repos.default to owner/repo or map its label in repos.byLabel', + ) + } + if (configuredRepos.length === 1) { + const directPath = await findDirectGithubIssuePath(mount, configuredRepos[0]!, number) + if (directPath) return directPath } - const defaultRepo = config.repos.default?.toLowerCase() - const matches = (await mount.listTree('/github/repos')) + + const roots = configuredRepos.length > 0 + ? configuredRepos.flatMap(githubIssueRoots) + : ['/github/repos'] + const matches = (await Promise.all([...new Set(roots)].map((root) => mount.listTree(root)))) + .flat() .filter((path) => path.endsWith('.json')) .filter((path) => { const parts = githubIssuePathParts(path) if (!parts || parts.number !== number) return false - return !defaultRepo || `${parts.owner}/${parts.repo}`.toLowerCase() === defaultRepo + return configuredRepos.length === 0 || configuredRepos.some((repo) => + repo.toLowerCase() === `${parts.owner}/${parts.repo}`.toLowerCase(), + ) }) .sort((left, right) => githubIssuePathPreference(left) - githubIssuePathPreference(right) || left.localeCompare(right)) if (matches.length === 0) { - throw new Error(`Unable to resolve GitHub issue ${key}: found 0 matches`) + throw new Error(`${githubIssueResolutionError(config, key)}: found 0 matches`) } const matchesByRepo = new Map() for (const path of matches) { @@ -977,10 +1019,10 @@ async function findIssuePath(mount: MountClient, key: string, config: FactoryCon matchesByRepo.set(repo.toLowerCase(), { repo, path }) } } - if (!defaultRepo && matchesByRepo.size > 1) { + if (!config.repos.default && matchesByRepo.size > 1) { const repos = [...matchesByRepo.values()].map((match) => match.repo).sort((left, right) => left.localeCompare(right)) throw new Error( - `Unable to resolve GitHub issue ${key}: matches multiple repositories (${repos.join(', ')}); ` + + `${githubIssueResolutionError(config, key)}: matches multiple repositories (${repos.join(', ')}); ` + 'set repos.default or pass a repo-qualified argument', ) } @@ -989,11 +1031,114 @@ async function findIssuePath(mount: MountClient, key: string, config: FactoryCon const matches = (await mount.listTree('/linear/issues/')) .filter((path) => path.startsWith(`/linear/issues/${key}__`) || path === `/linear/issues/${key}.json`) if (matches.length !== 1) { - throw new Error(`Unable to resolve issue ${key}: found ${matches.length} matches`) + const detected = autoDetectedIssueSources.has(config) ? ', auto-detected' : '' + throw new Error( + `Unable to resolve issue ${key} in Linear (issueSource=linear${detected}): found ${matches.length} matches; ` + + `set issueSource: 'github' if these are GitHub issues`, + ) } return matches[0] } +function configuredGithubIssueRepos(config: FactoryConfig): string[] { + const candidates = config.repos.default + ? [config.repos.default] + : [ + ...Object.values(config.repos.byLabel), + ...Object.values(config.repos.byProject), + ...config.repos.keywordRules.map((rule) => rule.repo), + ] + const repos = new Map() + const routedRepos = [ + ...Object.values(config.repos.byLabel), + ...Object.values(config.repos.byProject), + ...config.repos.keywordRules.map((rule) => rule.repo), + ] + for (const candidate of candidates) { + const mapped = Object.entries(config.repos.byLabel).find(([label]) => + label.toLowerCase() === candidate.toLowerCase(), + )?.[1] + const canonicalRoute = routedRepos.find((route) => + route.includes('/') && route.toLowerCase() === candidate.toLowerCase(), + ) + const repo = candidate.includes('/') + ? canonicalRoute ?? candidate + : mapped?.includes('/') + ? mapped + : config.repos.org ? `${config.repos.org}/${mapped ?? candidate}` : undefined + if (!repo || !/^[^/]+\/[^/]+$/u.test(repo)) continue + repos.set(repo.toLowerCase(), repo) + } + return [...repos.values()] +} + +function hasConfiguredGithubIssueRoutes(config: FactoryConfig): boolean { + return Boolean( + config.repos.default || + Object.keys(config.repos.byLabel).length > 0 || + Object.keys(config.repos.byProject).length > 0 || + config.repos.keywordRules.length > 0, + ) +} + +async function findDirectGithubIssuePath( + mount: MountClient, + repo: string, + number: number, +): Promise { + for (const path of githubIssueDirectPaths(repo, number)) { + try { + await mount.readFile(path) + return path + } catch (error) { + if (!isMountFileNotFound(error)) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Unable to read GitHub issue candidate ${path}: ${message}`, { cause: error }) + } + // Direct reads are an optimization. A scoped tree scan below handles + // slugged directories and older mount shapes when these paths are absent. + } + } + return undefined +} + +function isMountFileNotFound(error: unknown): boolean { + const record = asRecord(error) + const response = asRecord(record.response) + const status = record.status ?? record.statusCode ?? response.status ?? response.statusCode + const code = typeof record.code === 'string' ? record.code.toLowerCase() : undefined + const message = error instanceof Error ? error.message : String(error) + return status === 404 || status === '404' || + code === 'not_found' || code === 'file_not_found' || + /(?:file\s+not\s+found|\b404\b)/iu.test(message) +} + +function githubIssueDirectPaths(repo: string, number: number): string[] { + const [owner, name] = repo.split('/') as [string, string] + const roots = [ + `/github/repos/${owner}__${name}/issues`, + `/github/repos/${owner}/${name}/issues`, + ] + return [ + ...roots.map((root) => `${root}/${number}/meta.json`), + ...roots.map((root) => `${root}/by-id/${number}.json`), + ...roots.map((root) => `${root}/${number}.json`), + ] +} + +function githubIssueRoots(repo: string): string[] { + const [owner, name] = repo.split('/') as [string, string] + return [ + `/github/repos/${owner}__${name}/issues`, + `/github/repos/${owner}/${name}/issues`, + ] +} + +function githubIssueResolutionError(config: FactoryConfig, key: string): string { + const detected = autoDetectedIssueSources.has(config) ? ', auto-detected' : '' + return `Unable to resolve GitHub issue ${key} (issueSource=github${detected})` +} + const githubIssuePathPreference = (path: string): number => path.endsWith('/meta.json') ? 0 : path.includes('/by-id/') ? 1 : 2 diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index a2cf8fe..b001092 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -52,6 +52,7 @@ class FakeRelayFileClient implements RelayFileClientLike { readonly listLastNChangesCalls: Array<{ limit: number; context?: { workspaceId: string } }> = [] readonly getOpCalls: Array<{ workspaceId: string; opId: string }> = [] getSyncStatus?: RelayFileClientLike['getSyncStatus'] + treePageSize?: number files = new Map() ops = new Map() @@ -119,10 +120,17 @@ class FakeRelayFileClient implements RelayFileClientLike { async listTree(workspaceId: string, options?: { path?: string; depth?: number; cursor?: string }) { this.listTreeCalls.push({ workspaceId, options }) + const paths = [...this.files.keys()] + .filter((path) => path.startsWith(options?.path ?? '/')) + .sort() + const start = Number(options?.cursor ?? 0) + const pageSize = this.treePageSize ?? paths.length + const page = paths.slice(start, start + pageSize) + const next = start + page.length return { path: options?.path ?? '/', - entries: [...this.files.keys()].map((path) => ({ path, type: 'file' as const, revision: '1' })), - nextCursor: null, + entries: page.map((path) => ({ path, type: 'file' as const, revision: '1' })), + nextCursor: next < paths.length ? String(next) : null, } } @@ -351,6 +359,31 @@ describe('RelayfileCloudMountClient', () => { expect(fake.getEventsCalls[0]).toEqual({ workspaceId: 'rw_test', opts: { cursor: 'evt-0', limit: 10 } }) }) + it('paginates listTree to exhaustion', async () => { + const fake = new FakeRelayFileClient() + fake.treePageSize = 2 + for (const number of [1, 2, 3, 4, 5]) { + fake.files.set(`/github/repos/AgentWorkforce__factory/issues/by-id/${number}.json`, { + revision: '1', + content: '{}', + contentType: 'application/json', + }) + } + fake.files.set('/linear/issues/AR-1.json', { + revision: '1', + content: '{}', + contentType: 'application/json', + }) + const mount = new RelayfileCloudMountClient({ workspaceId: 'rw_test', client: fake }) + + await expect(mount.listTree('/github/repos/AgentWorkforce__factory/issues')).resolves.toEqual( + [1, 2, 3, 4, 5].map((number) => + `/github/repos/AgentWorkforce__factory/issues/by-id/${number}.json`, + ), + ) + expect(fake.listTreeCalls.map((call) => call.options?.cursor)).toEqual([undefined, '2', '4']) + }) + it('uses recent change-log events for provider-filtered getEvents tail reads', async () => { const fake = new FakeRelayFileClient() fake.events = [