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
Empty file modified bin/factory.mjs
100644 → 100755
Empty file.
3 changes: 3 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('FactoryConfigSchema', () => {
humanReview: 'In Human Review',
},
statesByTeam: {},
teamIds: {},
})
expect(parsed.safety).toEqual({
requireTitlePrefix: '[factory-e2e]',
Expand Down Expand Up @@ -113,13 +114,15 @@ 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' },
},
},
})

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({})
})
Expand Down
3 changes: 2 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
102 changes: 101 additions & 1 deletion src/orchestrator/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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<string[]> {
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({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down
157 changes: 125 additions & 32 deletions src/orchestrator/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1629,24 +1629,26 @@ export class FactoryLoop implements Factory {

async #githubIssuePaths(): Promise<string[]> {
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<string>()
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +4134 to +4135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If issue.description is null or undefined at runtime, calling .split() directly on it will throw a TypeError and crash the process. Enforce defensive programming by safely defaulting it to an empty string before splitting.

Suggested change
const sourceUrlLine = issue.description
.split(/\r?\n/u)
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the regular expression /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[/?#].*)?$/iu uses + quantifiers for both capture groups, match[1] and match[2] are guaranteed to be non-empty strings if a match is found. The check match?.[1] && match[2] is redundant and can be simplified to just checking if match is truthy.

Suggested change
return match?.[1] && match[2] ? `${match[1]}/${match[2]}` : undefined
return match ? match[1] + '/' + match[2] : undefined

}

function labelRoutesForIssue(
issue: LinearIssue,
config: FactoryConfig,
Expand Down Expand Up @@ -4363,22 +4430,26 @@ const githubIssueMirrorPayload = (
repoLabel: string,
config: FactoryConfig,
readyForAgentStateId: string,
): Record<string, unknown> => ({
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<string, unknown> => {
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()
Expand Down Expand Up @@ -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/`
Expand Down