@agent-relay/factory 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,8 @@ const MERGE_GATE_POLL_DELAY_MS = 10_000;
55
55
  const MAX_LABEL_IMPLEMENTERS = 4;
56
56
  const DISPATCH_FAILURE_HANDOFF_UNRESOLVED_TTL_MS = 5 * 60_000;
57
57
  const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
58
+ const REMOTE_OPERATION_PROGRESS_INTERVAL_MS = 15_000;
59
+ const REMOTE_OPERATION_SLOW_WARN_MS = 30_000;
58
60
  const GITHUB_FACTORY_LABEL = 'factory';
59
61
  const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
60
62
  const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
@@ -875,54 +877,180 @@ export class FactoryLoop {
875
877
  }
876
878
  async runOnce(opts = {}) {
877
879
  const dryRun = opts.dryRun ?? this.#config.dryRun;
878
- await this.#ingestGithubIssues({ dryRun });
879
- const paths = await this.#readyIssuePaths();
880
- const pulled = [];
881
- const triaged = [];
882
- const dispatched = [];
883
- const skipped = [];
884
- for (const path of paths) {
885
- const issue = await this.#readIssue(path);
886
- if (issue) {
887
- await this.#recordCanonicalIssueState(issue);
888
- }
889
- if (!issue) {
890
- continue;
891
- }
892
- pulled.push(issueRef(issue));
893
- const dispatchBlock = await this.#dispatchBlockReason(issue);
894
- if (dispatchBlock) {
895
- skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
896
- continue;
897
- }
898
- const batch = await this.#batch();
899
- if (batch.isInFlight(issue) || batch.isQueued(issue)) {
900
- skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
901
- continue;
880
+ const startedAtMs = this.#clock.now();
881
+ const relayfileWaitWarningsAtStart = this.#counters.relayfileOperationWaitWarnings ?? 0;
882
+ const relayfileSlowOperationsAtStart = this.#counters.relayfileSlowOperations ?? 0;
883
+ const relayfileOperationFailuresAtStart = this.#counters.relayfileOperationFailures ?? 0;
884
+ this.#logger.info?.('[factory] run-once started', { dryRun });
885
+ let report;
886
+ try {
887
+ await this.#ingestGithubIssues({ dryRun });
888
+ const paths = await this.#readyIssuePaths();
889
+ const pulled = [];
890
+ const triaged = [];
891
+ const dispatched = [];
892
+ const skipped = [];
893
+ let lastReadyReadProgressAtMs = this.#clock.now();
894
+ let readyIssueReads = 0;
895
+ for (const path of paths) {
896
+ const issue = await this.#readIssue(path);
897
+ readyIssueReads += 1;
898
+ lastReadyReadProgressAtMs = this.#logTimedProgress('[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
899
+ if (issue) {
900
+ await this.#recordCanonicalIssueState(issue);
901
+ }
902
+ if (!issue) {
903
+ continue;
904
+ }
905
+ pulled.push(issueRef(issue));
906
+ const dispatchBlock = await this.#dispatchBlockReason(issue);
907
+ if (dispatchBlock) {
908
+ skipped.push({ issue: issueRef(issue), reason: dispatchBlock });
909
+ continue;
910
+ }
911
+ const batch = await this.#batch();
912
+ if (batch.isInFlight(issue) || batch.isQueued(issue)) {
913
+ skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
914
+ continue;
915
+ }
916
+ if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
917
+ skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
918
+ continue;
919
+ }
920
+ if (!isInFactoryScope(issue, this.#config.safety)) {
921
+ skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
922
+ continue;
923
+ }
924
+ if (!isRealLinearIssue(issue)) {
925
+ skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
926
+ continue;
927
+ }
928
+ const decision = await this.triageIssue(issue);
929
+ triaged.push(decision);
930
+ const result = await this.dispatch(decision, { dryRun });
931
+ if (result.agents.length === 0 && !dryRun) {
932
+ skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
933
+ }
934
+ else {
935
+ dispatched.push(result);
936
+ }
902
937
  }
903
- if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
904
- skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
905
- continue;
938
+ report = { pulled, triaged, dispatched, skipped, dryRun, slackDegraded: this.#slackDegraded };
939
+ return report;
940
+ }
941
+ catch (error) {
942
+ this.#logger.warn?.('[factory] run-once failed', {
943
+ dryRun,
944
+ elapsedMs: this.#elapsedSince(startedAtMs),
945
+ error: describeError(error).errorMessage,
946
+ });
947
+ throw error;
948
+ }
949
+ finally {
950
+ if (report) {
951
+ this.#logger.info?.('[factory] run-once completed', {
952
+ dryRun,
953
+ elapsedMs: this.#elapsedSince(startedAtMs),
954
+ readyIssues: report.pulled.length,
955
+ triaged: report.triaged.length,
956
+ dispatched: report.dispatched.length,
957
+ skipped: report.skipped.length,
958
+ slackDegraded: report.slackDegraded ?? false,
959
+ relayfileWaitWarnings: (this.#counters.relayfileOperationWaitWarnings ?? 0) - relayfileWaitWarningsAtStart,
960
+ relayfileSlowOperations: (this.#counters.relayfileSlowOperations ?? 0) - relayfileSlowOperationsAtStart,
961
+ relayfileOperationFailures: (this.#counters.relayfileOperationFailures ?? 0) - relayfileOperationFailuresAtStart,
962
+ });
906
963
  }
907
- if (!isInFactoryScope(issue, this.#config.safety)) {
908
- skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
909
- continue;
964
+ }
965
+ }
966
+ async #listRelayfileTree(prefix, phase) {
967
+ return this.#withRelayfileOperation('listTree', { phase, prefix }, () => this.#mount.listTree(prefix), {
968
+ count: (paths) => paths.length,
969
+ logFailure: true,
970
+ logStart: true,
971
+ logComplete: true,
972
+ });
973
+ }
974
+ async #ensureRelayfileSubRoot(prefix, phase, opts) {
975
+ return this.#withRelayfileOperation('ensureSubRoot', { phase, prefix }, () => this.#mount.ensureSubRoot(prefix, opts), {
976
+ logFailure: true,
977
+ logStart: true,
978
+ logComplete: true,
979
+ });
980
+ }
981
+ async #readRelayfileFile(path, phase) {
982
+ return this.#withRelayfileOperation('readFile', { phase, path }, () => this.#mount.readFile(path));
983
+ }
984
+ async #withRelayfileOperation(operation, details, fn, opts = {}) {
985
+ const startedAtMs = this.#clock.now();
986
+ const metadata = { operation, ...details };
987
+ let waitWarnings = 0;
988
+ if (opts.logStart) {
989
+ this.#logger.info?.(`[factory] relayfile ${operation} started`, metadata);
990
+ }
991
+ const progressTimer = this.#logger.warn
992
+ ? setInterval(() => {
993
+ waitWarnings += 1;
994
+ this.#increment('relayfileOperationWaitWarnings');
995
+ this.#logger.warn?.('[factory] relayfile operation still waiting on relayfile cloud', {
996
+ ...metadata,
997
+ elapsedMs: this.#elapsedSince(startedAtMs),
998
+ });
999
+ }, REMOTE_OPERATION_PROGRESS_INTERVAL_MS)
1000
+ : undefined;
1001
+ if (progressTimer) {
1002
+ progressTimer.unref?.();
1003
+ }
1004
+ try {
1005
+ const result = await fn();
1006
+ const elapsedMs = this.#elapsedSince(startedAtMs);
1007
+ const count = opts.count?.(result);
1008
+ if (opts.logComplete) {
1009
+ this.#logger.info?.(`[factory] relayfile ${operation} completed`, {
1010
+ ...metadata,
1011
+ elapsedMs,
1012
+ ...(count === undefined ? {} : { count }),
1013
+ });
910
1014
  }
911
- if (!isRealLinearIssue(issue)) {
912
- skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
913
- continue;
1015
+ if (waitWarnings === 0 && elapsedMs >= REMOTE_OPERATION_SLOW_WARN_MS) {
1016
+ this.#increment('relayfileSlowOperations');
1017
+ this.#logger.warn?.('[factory] relayfile operation was slow', {
1018
+ ...metadata,
1019
+ elapsedMs,
1020
+ });
914
1021
  }
915
- const decision = await this.triageIssue(issue);
916
- triaged.push(decision);
917
- const result = await this.dispatch(decision, { dryRun });
918
- if (result.agents.length === 0 && !dryRun) {
919
- skipped.push({ issue: decision.issue, reason: 'queued or escalated' });
1022
+ return result;
1023
+ }
1024
+ catch (error) {
1025
+ if (opts.logFailure || waitWarnings > 0) {
1026
+ this.#increment('relayfileOperationFailures');
1027
+ this.#logger.warn?.('[factory] relayfile operation failed', {
1028
+ ...metadata,
1029
+ elapsedMs: this.#elapsedSince(startedAtMs),
1030
+ error: describeError(error).errorMessage,
1031
+ });
920
1032
  }
921
- else {
922
- dispatched.push(result);
1033
+ throw error;
1034
+ }
1035
+ finally {
1036
+ if (progressTimer) {
1037
+ clearInterval(progressTimer);
923
1038
  }
924
1039
  }
925
- return { pulled, triaged, dispatched, skipped, dryRun, slackDegraded: this.#slackDegraded };
1040
+ }
1041
+ #elapsedSince(startedAtMs) {
1042
+ return Math.max(0, this.#clock.now() - startedAtMs);
1043
+ }
1044
+ #logTimedProgress(message, startedAtMs, lastLoggedAtMs, metadata) {
1045
+ const now = this.#clock.now();
1046
+ if (now - lastLoggedAtMs < REMOTE_OPERATION_PROGRESS_INTERVAL_MS) {
1047
+ return lastLoggedAtMs;
1048
+ }
1049
+ this.#logger.info?.(message, {
1050
+ ...metadata,
1051
+ elapsedMs: Math.max(0, now - startedAtMs),
1052
+ });
1053
+ return now;
926
1054
  }
927
1055
  async runLoop(opts = {}) {
928
1056
  const maxIterations = Math.min(5, Math.max(1, Math.trunc(opts.maxIterations ?? this.#config.loop.maxIterations)));
@@ -1220,7 +1348,7 @@ export class FactoryLoop {
1220
1348
  if (this.#githubIngestionEnabled !== undefined) {
1221
1349
  return this.#githubIngestionEnabled;
1222
1350
  }
1223
- const githubReady = await this.#mount.ensureSubRoot(GITHUB_ISSUE_ROOT, { timeoutMs: 90_000 });
1351
+ const githubReady = await this.#ensureRelayfileSubRoot(GITHUB_ISSUE_ROOT, 'GitHub issue ingestion readiness', { timeoutMs: 90_000 });
1224
1352
  this.#githubIngestionEnabled = githubReady === 'ready';
1225
1353
  if (!this.#githubIngestionEnabled) {
1226
1354
  this.#logger.warn?.(`[factory] ${GITHUB_ISSUE_ROOT} sub-root is not mounted; GitHub issue ingestion disabled`);
@@ -1231,13 +1359,25 @@ export class FactoryLoop {
1231
1359
  if (!await this.#ensureGithubIngestionReady()) {
1232
1360
  return;
1233
1361
  }
1362
+ const startedAtMs = this.#clock.now();
1363
+ this.#logger.info?.('[factory] GitHub issue ingestion started', { dryRun: opts.dryRun ?? false });
1234
1364
  // Load the existing Linear mirror candidates once for the whole pass so
1235
1365
  // dedupe stays O(N + M) reads instead of re-scanning ISSUE_ROOT for every
1236
1366
  // GitHub issue (gemini perf finding on #findGithubIssueMirror).
1237
1367
  const candidates = this.#newMirrorCandidateCache();
1238
- for (const path of await this.#githubIssuePaths()) {
1368
+ const paths = await this.#githubIssuePaths();
1369
+ let processed = 0;
1370
+ let lastProgressAtMs = this.#clock.now();
1371
+ for (const path of paths) {
1239
1372
  await this.#handleGithubIssueChange(path, { ...opts, candidates });
1373
+ processed += 1;
1374
+ lastProgressAtMs = this.#logTimedProgress('[factory] GitHub issue ingestion progress', startedAtMs, lastProgressAtMs, { processed, total: paths.length, path });
1240
1375
  }
1376
+ this.#logger.info?.('[factory] GitHub issue ingestion completed', {
1377
+ dryRun: opts.dryRun ?? false,
1378
+ elapsedMs: this.#elapsedSince(startedAtMs),
1379
+ issues: paths.length,
1380
+ });
1241
1381
  }
1242
1382
  // Lazily lists+reads ISSUE_ROOT mirror candidates at most once, then memoizes
1243
1383
  // them for reuse across every GitHub issue handled in the same pass.
@@ -1253,40 +1393,53 @@ export class FactoryLoop {
1253
1393
  };
1254
1394
  }
1255
1395
  async #loadLinearMirrorCandidates() {
1396
+ const startedAtMs = this.#clock.now();
1397
+ this.#logger.info?.('[factory] Linear mirror candidate loading started');
1256
1398
  const candidates = [];
1257
- for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
1399
+ let scanned = 0;
1400
+ let lastProgressAtMs = startedAtMs;
1401
+ for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'GitHub mirror candidate loading')) {
1258
1402
  if (!isLinearIssueMirrorCandidatePath(path)) {
1259
1403
  continue;
1260
1404
  }
1405
+ scanned += 1;
1261
1406
  const issue = await this.#readIssue(path);
1262
1407
  if (issue) {
1263
1408
  candidates.push(issue);
1264
1409
  }
1410
+ lastProgressAtMs = this.#logTimedProgress('[factory] Linear mirror candidate loading progress', startedAtMs, lastProgressAtMs, { scanned, candidates: candidates.length, path });
1265
1411
  }
1412
+ this.#logger.info?.('[factory] Linear mirror candidate loading completed', {
1413
+ elapsedMs: this.#elapsedSince(startedAtMs),
1414
+ scanned,
1415
+ candidates: candidates.length,
1416
+ });
1266
1417
  return candidates;
1267
1418
  }
1268
1419
  async #githubIssuePaths() {
1269
1420
  try {
1270
- const paths = await this.#mount.listTree(GITHUB_ISSUE_ROOT);
1271
- const issuePaths = [];
1272
- for (const path of paths) {
1273
- if (githubIssuePathParts(path) !== undefined) {
1274
- issuePaths.push(path);
1275
- }
1276
- else if (githubIssueDirectoryPathParts(path) !== undefined) {
1277
- // listTree returns the issue directory entry alongside its
1278
- // meta.json file; githubIssuePathParts() already collected the
1279
- // file, so skip the directory to avoid reading the same issue
1280
- // twice in one backfill pass. Directory paths are only meaningful
1281
- // for live change events, not the tree scan.
1282
- continue;
1283
- }
1284
- else if (isGithubIssueTreePath(path)) {
1285
- this.#increment('githubIssuesIgnoredByPathRegex');
1286
- this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path });
1421
+ const issuePaths = new Set();
1422
+ for (const root of githubIssueScanRoots(this.#config)) {
1423
+ const paths = await this.#listRelayfileTree(root, 'GitHub issue ingestion');
1424
+ for (const path of paths) {
1425
+ if (githubIssuePathParts(path) !== undefined) {
1426
+ issuePaths.add(path);
1427
+ }
1428
+ else if (githubIssueDirectoryPathParts(path) !== undefined) {
1429
+ // listTree returns the issue directory entry alongside its
1430
+ // meta.json file; githubIssuePathParts() already collected the
1431
+ // file, so skip the directory to avoid reading the same issue
1432
+ // twice in one backfill pass. Directory paths are only meaningful
1433
+ // for live change events, not the tree scan.
1434
+ continue;
1435
+ }
1436
+ else if (isGithubIssueTreePath(path)) {
1437
+ this.#increment('githubIssuesIgnoredByPathRegex');
1438
+ this.#logger.debug?.('[factory] ignored GitHub issue path with unsupported relayfile shape', { path });
1439
+ }
1287
1440
  }
1288
1441
  }
1289
- return issuePaths.sort();
1442
+ return [...issuePaths].sort();
1290
1443
  }
1291
1444
  catch (error) {
1292
1445
  this.#increment('githubIssueListFailures');
@@ -1351,7 +1504,7 @@ export class FactoryLoop {
1351
1504
  try {
1352
1505
  for (const candidatePath of candidatePaths) {
1353
1506
  try {
1354
- const { content } = await this.#mount.readFile(candidatePath);
1507
+ const { content } = await this.#readRelayfileFile(candidatePath, 'GitHub issue ingestion');
1355
1508
  return parseGithubIssue(candidatePath, content);
1356
1509
  }
1357
1510
  catch (error) {
@@ -1374,7 +1527,7 @@ export class FactoryLoop {
1374
1527
  async #findGithubIssueMirror(ghIssue, candidates) {
1375
1528
  const draftPath = githubIssueMirrorDraftPath(ghIssue);
1376
1529
  try {
1377
- return parseLinearIssue(draftPath, (await this.#mount.readFile(draftPath)).content);
1530
+ return parseLinearIssue(draftPath, (await this.#readRelayfileFile(draftPath, 'GitHub issue mirror lookup')).content);
1378
1531
  }
1379
1532
  catch {
1380
1533
  // The draft path only exists before the Linear provider reconciles the
@@ -1599,14 +1752,14 @@ export class FactoryLoop {
1599
1752
  async #readyIssuePaths() {
1600
1753
  const pathsByKey = new Map();
1601
1754
  const canonicalPathsByKey = new Map();
1602
- for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
1755
+ for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'Linear ready issue canonical discovery')) {
1603
1756
  if (isIssueFilePath(path)) {
1604
1757
  const key = keyFromPath(path);
1605
1758
  canonicalPathsByKey.set(key, path);
1606
1759
  pathsByKey.set(key, path);
1607
1760
  }
1608
1761
  }
1609
- for (const path of await this.#mount.listTree(linearByStatePath('ready-for-agent'))) {
1762
+ for (const path of await this.#listRelayfileTree(linearByStatePath('ready-for-agent'), 'Linear ready issue alias discovery')) {
1610
1763
  if (isIssueAliasFilePath(path)) {
1611
1764
  const canonicalPath = canonicalPathsByKey.get(keyFromPath(path));
1612
1765
  if (canonicalPath) {
@@ -1626,7 +1779,9 @@ export class FactoryLoop {
1626
1779
  // /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
1627
1780
  // record lands at the by-id / by-uuid aliases. Read the canonical sibling
1628
1781
  // when the primary parses empty so triage sees real state.
1629
- const issue = await readLinearIssueWithCanonicalFallback(this.#mount, path);
1782
+ const issue = await readLinearIssueWithCanonicalFallback({
1783
+ readFile: (candidatePath) => this.#readRelayfileFile(candidatePath, 'Linear canonical issue read'),
1784
+ }, path);
1630
1785
  // Synced Linear records may carry only the state NAME, not the state UUID
1631
1786
  // (relayfile-adapters#205). The factory matches state by UUID, so backfill
1632
1787
  // the id from the name when the payload omitted it — otherwise every issue
@@ -2293,10 +2448,9 @@ export class FactoryLoop {
2293
2448
  if (!snapshot) {
2294
2449
  return;
2295
2450
  }
2296
- // Map the PR to an in-flight issue by its head ref (reuses the existing
2297
- // key matcher; no branch-naming assumption).
2298
- const headRef = snapshot.headRef ?? '';
2299
- const record = (await this.#batch()).inFlight.find((candidate) => !candidate.dryRun && headRef && containsIssueKey(headRef, candidate.issue.key));
2451
+ // Map the PR to an in-flight issue using the same precedence as the
2452
+ // post-merge path: branch name first, then title/body issue references.
2453
+ const record = this.#inFlightIssueForPrSnapshot(snapshot, await this.#batch());
2300
2454
  if (prMetaShowsMerged(snapshot)) {
2301
2455
  await this.#advanceMergedPrToDone(snapshot, record);
2302
2456
  return;
@@ -2316,6 +2470,19 @@ export class FactoryLoop {
2316
2470
  }
2317
2471
  await this.#ensureBabysitter(record, { repo: `${parts.owner}/${parts.repo}`, prNumber: snapshot.number, url: snapshot.url, path });
2318
2472
  }
2473
+ #inFlightIssueForPrSnapshot(snapshot, batch) {
2474
+ let best;
2475
+ for (const record of batch.inFlight) {
2476
+ if (record.dryRun) {
2477
+ continue;
2478
+ }
2479
+ const score = prSnapshotIssueMatchScore(snapshot, record.issue.key);
2480
+ if (score > 0 && (!best || score > best.score)) {
2481
+ best = { record, score };
2482
+ }
2483
+ }
2484
+ return best?.record;
2485
+ }
2319
2486
  async #advanceMergedPrToDone(snapshot, record) {
2320
2487
  if (record) {
2321
2488
  await this.#completeIssue(record, { targetState: 'done', runMergeGate: false, completionReason: 'pr-merged' });
@@ -3403,6 +3570,21 @@ function dispatchSpecs(decision) {
3403
3570
  function labelDerivedDispatchDecision(liveIssue, decision, config) {
3404
3571
  const routesByLabel = labelRoutesForIssue(liveIssue, config);
3405
3572
  if (routesByLabel.labels.length === 0) {
3573
+ const githubMirrorRoute = githubMirrorRouteForIssue(liveIssue, config);
3574
+ if (githubMirrorRoute) {
3575
+ const implementer = routeImplementerSpec(liveIssue, config, githubMirrorRoute.slug, githubMirrorRoute.route);
3576
+ return {
3577
+ ok: true,
3578
+ decision: {
3579
+ ...decision,
3580
+ routes: [githubMirrorRoute.route],
3581
+ scope: 'single',
3582
+ implementers: [implementer],
3583
+ workflow: undefined,
3584
+ reviewer: routeReviewerSpec(liveIssue, config, githubMirrorRoute.route, decision.reviewer),
3585
+ },
3586
+ };
3587
+ }
3406
3588
  // No repo labels — which is also what a label-less sync produces
3407
3589
  // (relayfile-adapters#205, labels dropped from the synced record). Fall back
3408
3590
  // to the configured default repo (consistent with triage, which already
@@ -3479,6 +3661,49 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
3479
3661
  },
3480
3662
  };
3481
3663
  }
3664
+ function githubMirrorRouteForIssue(issue, config) {
3665
+ const repo = githubMirrorRepoForIssue(issue);
3666
+ if (!repo) {
3667
+ return undefined;
3668
+ }
3669
+ const entry = findLabelRoute(config.repos.byLabel, repo)
3670
+ ?? findLabelRoute(config.repos.byLabel, repo.split('/').at(-1) ?? repo);
3671
+ if (!entry) {
3672
+ return undefined;
3673
+ }
3674
+ return {
3675
+ slug: entry.label,
3676
+ route: {
3677
+ repo: entry.repo,
3678
+ clonePath: config.repos.clonePaths[entry.repo],
3679
+ rationale: `GitHub mirror source ${repo} routes to ${entry.repo}.`,
3680
+ },
3681
+ };
3682
+ }
3683
+ function githubMirrorRepoForIssue(issue) {
3684
+ const payload = wrappedPayload(issue.raw);
3685
+ const source = asRecord(payload.source);
3686
+ if (stringValue(source?.provider)?.toLowerCase() === 'github') {
3687
+ const owner = stringValue(source?.owner);
3688
+ const repo = stringValue(source?.repo);
3689
+ if (owner && repo) {
3690
+ return `${owner}/${repo}`;
3691
+ }
3692
+ const urlRepo = githubRepoFromUrl(stringValue(source?.url));
3693
+ if (urlRepo) {
3694
+ return urlRepo;
3695
+ }
3696
+ }
3697
+ const sourceUrlLine = issue.description
3698
+ .split(/\r?\n/u)
3699
+ .map((line) => line.trim())
3700
+ .find((line) => line.startsWith(GITHUB_MIRROR_SOURCE_PREFIX));
3701
+ return githubRepoFromUrl(sourceUrlLine?.slice(GITHUB_MIRROR_SOURCE_PREFIX.length));
3702
+ }
3703
+ function githubRepoFromUrl(url) {
3704
+ const match = url?.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[/?#].*)?$/iu);
3705
+ return match?.[1] && match[2] ? `${match[1]}/${match[2]}` : undefined;
3706
+ }
3482
3707
  function labelRoutesForIssue(issue, config) {
3483
3708
  const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
3484
3709
  const routes = [];
@@ -3710,22 +3935,26 @@ const githubIssueDirectoryPathParts = (path) => {
3710
3935
  };
3711
3936
  const githubIssueHasFactoryLabel = (issue) => issue.labels.some((label) => label.toLowerCase() === GITHUB_FACTORY_LABEL);
3712
3937
  const githubIssueIsClosed = (issue) => issue.state === 'closed';
3713
- const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => ({
3714
- id: githubIssueMirrorId(issue),
3715
- title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
3716
- description: githubIssueMirrorDescription(issue),
3717
- stateId: readyForAgentStateId,
3718
- labels: [{ name: repoLabel }],
3719
- team: { key: config.safety.requireTeamKey },
3720
- source: {
3721
- provider: 'github',
3722
- owner: issue.owner,
3723
- repo: issue.repoName,
3724
- number: issue.number,
3725
- url: issue.url,
3726
- path: issue.path,
3727
- },
3728
- });
3938
+ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
3939
+ const teamId = config.linear.teamIds[config.safety.requireTeamKey];
3940
+ return {
3941
+ id: githubIssueMirrorId(issue),
3942
+ title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
3943
+ description: githubIssueMirrorDescription(issue),
3944
+ stateId: readyForAgentStateId,
3945
+ labels: [{ name: repoLabel }],
3946
+ ...(teamId ? { teamId } : {}),
3947
+ team: { key: config.safety.requireTeamKey, ...(teamId ? { id: teamId } : {}) },
3948
+ source: {
3949
+ provider: 'github',
3950
+ owner: issue.owner,
3951
+ repo: issue.repoName,
3952
+ number: issue.number,
3953
+ url: issue.url,
3954
+ path: issue.path,
3955
+ },
3956
+ };
3957
+ };
3729
3958
  const githubIssueMirrorDescription = (issue) => {
3730
3959
  const body = issue.body.trim();
3731
3960
  const source = `${GITHUB_MIRROR_SOURCE_PREFIX}${issue.url}`;
@@ -3830,6 +4059,27 @@ const reposFromConfig = (config) => {
3830
4059
  ].filter((repo) => Boolean(repo)));
3831
4060
  return [...repos];
3832
4061
  };
4062
+ const githubIssueScanRoots = (config) => {
4063
+ const roots = new Set([GITHUB_ISSUE_ROOT]);
4064
+ for (const repo of reposFromConfig(config)) {
4065
+ const parts = githubRepoParts(repo);
4066
+ if (!parts)
4067
+ continue;
4068
+ roots.add(`/github/repos/${parts.owner}__${parts.repo}/issues/by-id`);
4069
+ }
4070
+ return [...roots];
4071
+ };
4072
+ const githubRepoParts = (repo) => {
4073
+ const split = repo.match(/^([^/]+)\/([^/]+)$/u);
4074
+ if (split) {
4075
+ return { owner: split[1], repo: split[2] };
4076
+ }
4077
+ const compact = repo.match(/^([^/]+)__([^/]+)$/u);
4078
+ if (compact) {
4079
+ return { owner: compact[1], repo: compact[2] };
4080
+ }
4081
+ return undefined;
4082
+ };
3833
4083
  const githubPullRoot = (repo) => {
3834
4084
  const [owner, name] = repo.split('/');
3835
4085
  return owner && name ? `/github/repos/${owner}__${name}/pulls/by-id/` : `/github/repos/${repo}/pulls/by-id/`;