@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.
- package/bin/factory.mjs +0 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +14 -3
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +14 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +2 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +337 -87
- package/dist/orchestrator/factory.js.map +1 -1
- package/package.json +7 -6
|
@@ -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
|
-
|
|
879
|
-
const
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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 (
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (
|
|
919
|
-
|
|
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
|
-
|
|
922
|
-
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
finally {
|
|
1036
|
+
if (progressTimer) {
|
|
1037
|
+
clearInterval(progressTimer);
|
|
923
1038
|
}
|
|
924
1039
|
}
|
|
925
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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(
|
|
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
|
|
2297
|
-
//
|
|
2298
|
-
const
|
|
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
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
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/`;
|