@agent-relay/factory 0.1.13 → 0.1.14
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/README.md +17 -10
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +87 -9
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +26 -16
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +4 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/relayflow-registry.d.ts +61 -0
- package/dist/dispatch/relayflow-registry.d.ts.map +1 -0
- package/dist/dispatch/relayflow-registry.js +133 -0
- package/dist/dispatch/relayflow-registry.js.map +1 -0
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +159 -17
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/node/factory-node.d.ts +6 -5
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +58 -43
- package/dist/node/factory-node.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +2 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +316 -72
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +1 -1
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/writeback.d.ts +6 -0
- package/dist/ports/writeback.d.ts.map +1 -1
- package/dist/subscriptions/event-client.d.ts +1 -0
- package/dist/subscriptions/event-client.d.ts.map +1 -1
- package/dist/subscriptions/event-client.js +1 -0
- package/dist/subscriptions/event-client.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +19 -1
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +130 -0
- package/dist/writeback/github.js.map +1 -1
- package/dist/writeback/index.d.ts +2 -1
- package/dist/writeback/index.d.ts.map +1 -1
- package/dist/writeback/index.js +1 -1
- package/dist/writeback/index.js.map +1 -1
- package/package.json +8 -4
|
@@ -7,10 +7,11 @@ import { GithubMergeGate, closeProbePr } from '../github/index.js';
|
|
|
7
7
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
8
8
|
import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match.js';
|
|
9
9
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
10
|
+
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
10
11
|
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
11
12
|
import { renderAgentTask } from '../dispatch/templates.js';
|
|
12
13
|
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
|
|
13
|
-
import { MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
14
|
+
import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
14
15
|
import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
|
|
15
16
|
import { issueKey } from './batch-tracker.js';
|
|
16
17
|
import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
|
|
@@ -19,6 +20,7 @@ const ISSUE_ROOT = '/linear/issues';
|
|
|
19
20
|
const GITHUB_ISSUE_ROOT = '/github/repos';
|
|
20
21
|
const READY_EVENTS_LIMIT = 100;
|
|
21
22
|
const LIVE_ISSUE_GLOB = `${ISSUE_ROOT}/**`;
|
|
23
|
+
const LIVE_RELAYFLOW_GLOB = '/**';
|
|
22
24
|
// Subscribe broadly under /github/repos and let isGithubIssueFilePath() /
|
|
23
25
|
// githubPullPathParts() re-validate the exact shape in the callback.
|
|
24
26
|
// globMatchesPath() treats a non-terminal `**` as a single-segment wildcard, so
|
|
@@ -58,6 +60,7 @@ const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
|
58
60
|
const REMOTE_OPERATION_PROGRESS_INTERVAL_MS = 15_000;
|
|
59
61
|
const REMOTE_OPERATION_SLOW_WARN_MS = 30_000;
|
|
60
62
|
const GITHUB_FACTORY_LABEL = 'factory';
|
|
63
|
+
const GITHUB_LIFECYCLE_LABELS = new Set(['factory:in-progress', 'factory:human-review']);
|
|
61
64
|
const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
|
|
62
65
|
const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
|
|
63
66
|
export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
|
|
@@ -76,6 +79,7 @@ export class FactoryLoop {
|
|
|
76
79
|
#fleet;
|
|
77
80
|
#triage;
|
|
78
81
|
#linear;
|
|
82
|
+
#githubWriteback;
|
|
79
83
|
#slack;
|
|
80
84
|
#mergeGate;
|
|
81
85
|
#probeCloser;
|
|
@@ -91,6 +95,7 @@ export class FactoryLoop {
|
|
|
91
95
|
#terminationGraceMs;
|
|
92
96
|
#state;
|
|
93
97
|
#workspaceId;
|
|
98
|
+
#relayflows;
|
|
94
99
|
#batchView;
|
|
95
100
|
#batchReady;
|
|
96
101
|
#listeners = new Map();
|
|
@@ -158,6 +163,7 @@ export class FactoryLoop {
|
|
|
158
163
|
// undefined = readiness not yet probed; resolved lazily so the standalone
|
|
159
164
|
// runOnce() path (which skips #start) still ingests when the mount is present.
|
|
160
165
|
#githubIngestionEnabled;
|
|
166
|
+
#resolvedIssueSource;
|
|
161
167
|
#integrationInstructions;
|
|
162
168
|
#integrationInstructionsRefresh;
|
|
163
169
|
#starting;
|
|
@@ -177,6 +183,7 @@ export class FactoryLoop {
|
|
|
177
183
|
this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
|
|
178
184
|
safety: config.safety,
|
|
179
185
|
});
|
|
186
|
+
this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
|
|
180
187
|
this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
|
|
181
188
|
void (ports.github ?? MountGithubRead(ports.mount));
|
|
182
189
|
this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
|
|
@@ -195,6 +202,7 @@ export class FactoryLoop {
|
|
|
195
202
|
this.#readChildPids = ports.readChildPids;
|
|
196
203
|
this.#terminationGraceMs = ports.terminationGraceMs;
|
|
197
204
|
this.#workspaceId = config.workspaceId ?? 'default';
|
|
205
|
+
this.#relayflows = ports.relayflows;
|
|
198
206
|
this.#state = ports.stateStore ?? new InMemoryStateStore({
|
|
199
207
|
batchSize: config.batchSize,
|
|
200
208
|
agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
|
|
@@ -287,12 +295,19 @@ export class FactoryLoop {
|
|
|
287
295
|
}
|
|
288
296
|
async #start(opts) {
|
|
289
297
|
this.#stopping = false;
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
292
|
-
this.#
|
|
298
|
+
const issueSource = await this.#issueSource();
|
|
299
|
+
if (issueSource === 'linear') {
|
|
300
|
+
const ready = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
|
|
301
|
+
if (ready !== 'ready') {
|
|
302
|
+
this.#error(new Error(`${ISSUE_ROOT} sub-root is not mounted`));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const githubReady = await this.#ensureGithubIngestionReady();
|
|
307
|
+
if (issueSource === 'github' && !githubReady) {
|
|
308
|
+
this.#error(new Error(`${GITHUB_ISSUE_ROOT} sub-root is not mounted`));
|
|
293
309
|
return;
|
|
294
310
|
}
|
|
295
|
-
await this.#ensureGithubIngestionReady();
|
|
296
311
|
this.#wireFleetEvents();
|
|
297
312
|
if ((opts.mode ?? 'live') === 'live') {
|
|
298
313
|
this.#started = true;
|
|
@@ -309,7 +324,8 @@ export class FactoryLoop {
|
|
|
309
324
|
}
|
|
310
325
|
}
|
|
311
326
|
await this.#backfillReadyIssues();
|
|
312
|
-
this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
|
|
327
|
+
this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
|
|
328
|
+
void this.#dispatchRelayflowEvent(event);
|
|
313
329
|
// The SDK types `resource` as always-present, but the polling fallback and
|
|
314
330
|
// degraded-sync paths can deliver events without it. Skip those rather
|
|
315
331
|
// than throwing (which would otherwise crash the subscription handler).
|
|
@@ -415,7 +431,7 @@ export class FactoryLoop {
|
|
|
415
431
|
// LIVE_GITHUB_ISSUE_GLOB is a terminal `${GITHUB_ISSUE_ROOT}/**`, so it
|
|
416
432
|
// already covers the PR change events the babysitter consumes; pull-event
|
|
417
433
|
// *processing* is gated on babysitter.enabled in #prepareLiveEventForDrain.
|
|
418
|
-
this.#subscription = this.#mount.subscribe([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB], (event) => {
|
|
434
|
+
this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
|
|
419
435
|
this.#enqueueLiveEvent(event);
|
|
420
436
|
}, { from: 'now', coalesce: 'none' });
|
|
421
437
|
}
|
|
@@ -618,9 +634,12 @@ export class FactoryLoop {
|
|
|
618
634
|
const batch = events.splice(0, LIVE_EVENT_DRAIN_BATCH_SIZE);
|
|
619
635
|
const paths = [];
|
|
620
636
|
for (const event of batch) {
|
|
621
|
-
const
|
|
622
|
-
if (
|
|
623
|
-
|
|
637
|
+
const prepared = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
|
|
638
|
+
if (prepared.dispatchRelayflow) {
|
|
639
|
+
void this.#dispatchRelayflowEvent(event);
|
|
640
|
+
}
|
|
641
|
+
if (prepared.path) {
|
|
642
|
+
paths.push(prepared.path);
|
|
624
643
|
}
|
|
625
644
|
}
|
|
626
645
|
await Promise.all(paths.map((path) => this.#handlePreparedLiveChange(path)));
|
|
@@ -652,14 +671,18 @@ export class FactoryLoop {
|
|
|
652
671
|
return;
|
|
653
672
|
await this.#refreshLiveHeartbeat();
|
|
654
673
|
}
|
|
674
|
+
#subscriptionGlobs(factoryGlobs) {
|
|
675
|
+
return this.#relayflows ? [LIVE_RELAYFLOW_GLOB] : factoryGlobs;
|
|
676
|
+
}
|
|
655
677
|
async #prepareLiveEventForDrain(event, seenIssueKeys) {
|
|
656
678
|
const path = changeEventPath(event);
|
|
657
679
|
if (!path) {
|
|
658
|
-
return
|
|
680
|
+
return { dispatchRelayflow: false };
|
|
659
681
|
}
|
|
660
682
|
const isPullPath = isGithubPullFilePath(path);
|
|
661
|
-
|
|
662
|
-
|
|
683
|
+
const isFactoryPath = isIssueFilePath(path) || isGithubIssueFilePath(path) || isPullPath;
|
|
684
|
+
if (!isFactoryPath && !this.#relayflows) {
|
|
685
|
+
return { dispatchRelayflow: false };
|
|
663
686
|
}
|
|
664
687
|
if (isBeforeLiveCutoff(event.occurredAt, this.#liveConnectStartedAtMs, this.#liveReplaySkewMarginMs)) {
|
|
665
688
|
this.#increment('liveReplayEventsSuppressed');
|
|
@@ -671,7 +694,7 @@ export class FactoryLoop {
|
|
|
671
694
|
connectStartedAt: new Date(this.#liveConnectStartedAtMs).toISOString(),
|
|
672
695
|
replaySkewMarginMs: this.#liveReplaySkewMarginMs,
|
|
673
696
|
});
|
|
674
|
-
return
|
|
697
|
+
return { dispatchRelayflow: false };
|
|
675
698
|
}
|
|
676
699
|
if (isAtOrBeforeHighWatermark(event.id, this.#liveEventHighWatermark)) {
|
|
677
700
|
this.#increment('liveReplayEventsSuppressed');
|
|
@@ -681,7 +704,7 @@ export class FactoryLoop {
|
|
|
681
704
|
highWatermark: this.#liveEventHighWatermark,
|
|
682
705
|
path,
|
|
683
706
|
});
|
|
684
|
-
return
|
|
707
|
+
return { dispatchRelayflow: false };
|
|
685
708
|
}
|
|
686
709
|
const dedupeKey = liveEventDedupeKey(event);
|
|
687
710
|
if (dedupeKey) {
|
|
@@ -691,7 +714,7 @@ export class FactoryLoop {
|
|
|
691
714
|
id: event.id,
|
|
692
715
|
path,
|
|
693
716
|
});
|
|
694
|
-
return
|
|
717
|
+
return { dispatchRelayflow: false };
|
|
695
718
|
}
|
|
696
719
|
rememberLiveEvent(this.#seenLiveEvents, dedupeKey);
|
|
697
720
|
}
|
|
@@ -699,6 +722,9 @@ export class FactoryLoop {
|
|
|
699
722
|
this.#increment('liveEventsMissingIdentity');
|
|
700
723
|
this.#logger.warn?.('[factory] live issue event missing stable identity', { path });
|
|
701
724
|
}
|
|
725
|
+
if (!isFactoryPath) {
|
|
726
|
+
return { dispatchRelayflow: true };
|
|
727
|
+
}
|
|
702
728
|
if (isGithubIssueFilePath(path)) {
|
|
703
729
|
const sourceKey = `github:${path}`;
|
|
704
730
|
if (seenIssueKeys.has(sourceKey)) {
|
|
@@ -707,11 +733,11 @@ export class FactoryLoop {
|
|
|
707
733
|
id: event.id,
|
|
708
734
|
path,
|
|
709
735
|
});
|
|
710
|
-
return
|
|
736
|
+
return { dispatchRelayflow: false };
|
|
711
737
|
}
|
|
712
738
|
seenIssueKeys.add(sourceKey);
|
|
713
739
|
this.#recordArrivalLatency(event);
|
|
714
|
-
return path;
|
|
740
|
+
return { path, dispatchRelayflow: true };
|
|
715
741
|
}
|
|
716
742
|
if (isPullPath) {
|
|
717
743
|
// Dedupe PR change events by path within a drain; the babysitter routing
|
|
@@ -719,11 +745,11 @@ export class FactoryLoop {
|
|
|
719
745
|
const sourceKey = `pull:${path}`;
|
|
720
746
|
if (seenIssueKeys.has(sourceKey)) {
|
|
721
747
|
this.#increment('liveDuplicatePrEventsSuppressed');
|
|
722
|
-
return
|
|
748
|
+
return { dispatchRelayflow: false };
|
|
723
749
|
}
|
|
724
750
|
seenIssueKeys.add(sourceKey);
|
|
725
751
|
this.#recordArrivalLatency(event);
|
|
726
|
-
return path;
|
|
752
|
+
return { path, dispatchRelayflow: true };
|
|
727
753
|
}
|
|
728
754
|
const issueKey = keyFromPath(path);
|
|
729
755
|
if (seenIssueKeys.has(issueKey)) {
|
|
@@ -733,7 +759,7 @@ export class FactoryLoop {
|
|
|
733
759
|
path,
|
|
734
760
|
issue: issueKey,
|
|
735
761
|
});
|
|
736
|
-
return
|
|
762
|
+
return { dispatchRelayflow: false };
|
|
737
763
|
}
|
|
738
764
|
const issueRef = { key: issueKey, uuid: uuidFromPath(path) ?? issueKey, path };
|
|
739
765
|
const batch = await this.#batch();
|
|
@@ -744,11 +770,40 @@ export class FactoryLoop {
|
|
|
744
770
|
path,
|
|
745
771
|
issue: issueKey,
|
|
746
772
|
});
|
|
747
|
-
return
|
|
773
|
+
return { dispatchRelayflow: false };
|
|
748
774
|
}
|
|
749
775
|
seenIssueKeys.add(issueKey);
|
|
750
776
|
this.#recordArrivalLatency(event);
|
|
751
|
-
return path;
|
|
777
|
+
return { path, dispatchRelayflow: true };
|
|
778
|
+
}
|
|
779
|
+
async #dispatchRelayflowEvent(event) {
|
|
780
|
+
const relayflows = this.#relayflows;
|
|
781
|
+
if (!relayflows)
|
|
782
|
+
return;
|
|
783
|
+
try {
|
|
784
|
+
const result = await dispatchRelayflowForChangeEvent(event, relayflows.registry, {
|
|
785
|
+
cwd: relayflows.cwd ?? process.cwd(),
|
|
786
|
+
mountRoot: relayflows.mountRoot ?? this.#integrationsMountRoot(),
|
|
787
|
+
workflowRunner: relayflows.workflowRunner,
|
|
788
|
+
});
|
|
789
|
+
if (!result)
|
|
790
|
+
return;
|
|
791
|
+
this.#increment('relayflowEventsDispatched');
|
|
792
|
+
this.#logger.info?.('[factory] relayflow dispatched for integration event', {
|
|
793
|
+
trigger: result.trigger,
|
|
794
|
+
templatePath: result.templatePath,
|
|
795
|
+
runId: result.runId,
|
|
796
|
+
status: result.status,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
this.#increment('relayflowDispatchErrors');
|
|
801
|
+
this.#logger.warn?.('[factory] relayflow dispatch failed for integration event', {
|
|
802
|
+
eventId: event.id,
|
|
803
|
+
path: changeEventPath(event),
|
|
804
|
+
error,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
752
807
|
}
|
|
753
808
|
async #handlePreparedLiveChange(path) {
|
|
754
809
|
if (isGithubPullFilePath(path)) {
|
|
@@ -888,7 +943,13 @@ export class FactoryLoop {
|
|
|
888
943
|
this.#logger.info?.('[factory] run-once started', { dryRun });
|
|
889
944
|
let report;
|
|
890
945
|
try {
|
|
891
|
-
await this.#
|
|
946
|
+
const issueSource = await this.#issueSource();
|
|
947
|
+
if (issueSource === 'linear') {
|
|
948
|
+
await this.#ingestGithubIssues({ dryRun });
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
await this.#ensureGithubIngestionReady();
|
|
952
|
+
}
|
|
892
953
|
const paths = await this.#readyIssuePaths();
|
|
893
954
|
const pulled = [];
|
|
894
955
|
const triaged = [];
|
|
@@ -900,7 +961,7 @@ export class FactoryLoop {
|
|
|
900
961
|
const issue = await this.#readIssue(path);
|
|
901
962
|
readyIssueReads += 1;
|
|
902
963
|
lastReadyReadProgressAtMs = this.#logTimedProgress('[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
|
|
903
|
-
if (issue) {
|
|
964
|
+
if (issue && issueSource === 'linear') {
|
|
904
965
|
await this.#recordCanonicalIssueState(issue);
|
|
905
966
|
}
|
|
906
967
|
if (!issue) {
|
|
@@ -917,7 +978,7 @@ export class FactoryLoop {
|
|
|
917
978
|
skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
|
|
918
979
|
continue;
|
|
919
980
|
}
|
|
920
|
-
if (!this.#
|
|
981
|
+
if (!this.#isIssueReady(issue)) {
|
|
921
982
|
skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
|
|
922
983
|
continue;
|
|
923
984
|
}
|
|
@@ -925,7 +986,7 @@ export class FactoryLoop {
|
|
|
925
986
|
skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
|
|
926
987
|
continue;
|
|
927
988
|
}
|
|
928
|
-
if (!
|
|
989
|
+
if (!isDispatchableIssue(issue)) {
|
|
929
990
|
skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
|
|
930
991
|
continue;
|
|
931
992
|
}
|
|
@@ -1153,7 +1214,7 @@ export class FactoryLoop {
|
|
|
1153
1214
|
this.#error(error, decision.issue);
|
|
1154
1215
|
throw error;
|
|
1155
1216
|
}
|
|
1156
|
-
if (!
|
|
1217
|
+
if (!isDispatchableIssue(liveIssue)) {
|
|
1157
1218
|
const error = new Error(`Refusing to dispatch ${decision.issue.key}: not reconciled real Linear issue`);
|
|
1158
1219
|
this.#error(error, decision.issue);
|
|
1159
1220
|
throw error;
|
|
@@ -1179,7 +1240,7 @@ export class FactoryLoop {
|
|
|
1179
1240
|
const signature = labelDispatchFailureSignature(labelDispatch);
|
|
1180
1241
|
if (this.#labelDispatchFailures.get(decision.issue.key) !== signature) {
|
|
1181
1242
|
try {
|
|
1182
|
-
await this.#
|
|
1243
|
+
await this.#postIssueComment(liveIssue, comment);
|
|
1183
1244
|
// Record only after a successful post so a failed writeback retries
|
|
1184
1245
|
// next cycle rather than being suppressed as already-notified.
|
|
1185
1246
|
this.#labelDispatchFailures.set(decision.issue.key, signature);
|
|
@@ -1228,17 +1289,22 @@ export class FactoryLoop {
|
|
|
1228
1289
|
let implementingStateId;
|
|
1229
1290
|
if (!dryRun) {
|
|
1230
1291
|
const issue = await this.#readIssue(dispatchDecision.issue.path);
|
|
1231
|
-
if (!issue || !this.#
|
|
1292
|
+
if (!issue || !this.#isIssueReady(issue)) {
|
|
1232
1293
|
throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
|
|
1233
1294
|
}
|
|
1234
|
-
implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
|
|
1235
1295
|
try {
|
|
1236
|
-
await this.#
|
|
1296
|
+
await this.#postIssueComment(issue, comment);
|
|
1237
1297
|
}
|
|
1238
1298
|
catch (error) {
|
|
1239
1299
|
this.#logger.warn?.('[factory] comment writeback skipped', error);
|
|
1240
1300
|
}
|
|
1241
|
-
|
|
1301
|
+
if (isGithubIssue(issue)) {
|
|
1302
|
+
await this.#githubWriteback.setStatus(issue, 'in-progress');
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
|
|
1306
|
+
await this.#linear.setState(issue, implementingStateId);
|
|
1307
|
+
}
|
|
1242
1308
|
this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
|
|
1243
1309
|
}
|
|
1244
1310
|
const result = {
|
|
@@ -1320,24 +1386,24 @@ export class FactoryLoop {
|
|
|
1320
1386
|
}
|
|
1321
1387
|
}
|
|
1322
1388
|
async #handleChange(path, opts = {}) {
|
|
1323
|
-
if (!isIssueFilePath(path)) {
|
|
1389
|
+
if (!isIssueFilePath(path) && !isGithubIssueFilePath(path)) {
|
|
1324
1390
|
return;
|
|
1325
1391
|
}
|
|
1326
1392
|
try {
|
|
1327
1393
|
const issue = await this.#readIssue(path);
|
|
1328
|
-
if (issue) {
|
|
1394
|
+
if (issue && !isGithubIssue(issue)) {
|
|
1329
1395
|
await this.#recordCanonicalIssueState(issue);
|
|
1330
1396
|
}
|
|
1331
|
-
if (!issue || !this.#
|
|
1397
|
+
if (!issue || !this.#isIssueReady(issue)) {
|
|
1332
1398
|
return;
|
|
1333
1399
|
}
|
|
1334
|
-
if (opts.requireRealIssue && !
|
|
1400
|
+
if (opts.requireRealIssue && !isDispatchableIssue(issue)) {
|
|
1335
1401
|
return;
|
|
1336
1402
|
}
|
|
1337
1403
|
if (!isInFactoryScope(issue, this.#config.safety)) {
|
|
1338
1404
|
return;
|
|
1339
1405
|
}
|
|
1340
|
-
if (!
|
|
1406
|
+
if (!isDispatchableIssue(issue)) {
|
|
1341
1407
|
return;
|
|
1342
1408
|
}
|
|
1343
1409
|
const batch = await this.#batch();
|
|
@@ -1367,6 +1433,42 @@ export class FactoryLoop {
|
|
|
1367
1433
|
this.#logger.error?.('[factory] failed to handle issue change', error);
|
|
1368
1434
|
}
|
|
1369
1435
|
}
|
|
1436
|
+
async #issueSource() {
|
|
1437
|
+
if (this.#resolvedIssueSource) {
|
|
1438
|
+
return this.#resolvedIssueSource;
|
|
1439
|
+
}
|
|
1440
|
+
if (this.#config.issueSource) {
|
|
1441
|
+
this.#resolvedIssueSource = this.#config.issueSource;
|
|
1442
|
+
return this.#resolvedIssueSource;
|
|
1443
|
+
}
|
|
1444
|
+
const linearReady = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
|
|
1445
|
+
this.#resolvedIssueSource = linearReady === 'ready' ? 'linear' : 'github';
|
|
1446
|
+
if (this.#resolvedIssueSource === 'github') {
|
|
1447
|
+
this.#logger.info?.('[factory] Linear issue source is not connected; using GitHub issues');
|
|
1448
|
+
}
|
|
1449
|
+
return this.#resolvedIssueSource;
|
|
1450
|
+
}
|
|
1451
|
+
#isIssueReady(issue) {
|
|
1452
|
+
if (!isGithubIssue(issue)) {
|
|
1453
|
+
return this.#states.isRole(issue.stateId, 'readyForAgent');
|
|
1454
|
+
}
|
|
1455
|
+
const githubState = (issue.state?.name ?? stringValue(wrappedPayload(issue.raw).state) ?? '').trim().toLowerCase();
|
|
1456
|
+
if (githubState === 'closed') {
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
if (issue.labels.some((label) => GITHUB_LIFECYCLE_LABELS.has(label.trim().toLowerCase()))) {
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
const required = this.#config.safety.requireLabel.trim().toLowerCase();
|
|
1463
|
+
return Boolean(required) && issue.labels.some((label) => label.trim().toLowerCase() === required);
|
|
1464
|
+
}
|
|
1465
|
+
async #postIssueComment(issue, body) {
|
|
1466
|
+
if (isGithubIssue(issue)) {
|
|
1467
|
+
await this.#githubWriteback.postComment(issue, body);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
await this.#linear.postComment(issue, body);
|
|
1471
|
+
}
|
|
1370
1472
|
// Probes the GitHub issue sub-root at most once and caches the verdict so
|
|
1371
1473
|
// repeated iterations skip listTree calls when the mount is absent.
|
|
1372
1474
|
async #ensureGithubIngestionReady() {
|
|
@@ -1481,6 +1583,12 @@ export class FactoryLoop {
|
|
|
1481
1583
|
if (!ghIssue) {
|
|
1482
1584
|
return;
|
|
1483
1585
|
}
|
|
1586
|
+
if (await this.#issueSource() === 'github') {
|
|
1587
|
+
if (!githubIssueIsClosed(ghIssue) && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
1588
|
+
await this.#handleChange(ghIssue.path);
|
|
1589
|
+
}
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1484
1592
|
if (githubIssueIsClosed(ghIssue)) {
|
|
1485
1593
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
1486
1594
|
if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
|
|
@@ -1497,7 +1605,7 @@ export class FactoryLoop {
|
|
|
1497
1605
|
}
|
|
1498
1606
|
return;
|
|
1499
1607
|
}
|
|
1500
|
-
if (!githubIssueHasFactoryLabel(ghIssue)) {
|
|
1608
|
+
if (!githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
|
|
1501
1609
|
return;
|
|
1502
1610
|
}
|
|
1503
1611
|
const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
|
|
@@ -1775,6 +1883,9 @@ export class FactoryLoop {
|
|
|
1775
1883
|
}
|
|
1776
1884
|
}
|
|
1777
1885
|
async #readyIssuePaths() {
|
|
1886
|
+
if (await this.#issueSource() === 'github') {
|
|
1887
|
+
return this.#githubIssuePaths();
|
|
1888
|
+
}
|
|
1778
1889
|
const pathsByKey = new Map();
|
|
1779
1890
|
const canonicalPathsByKey = new Map();
|
|
1780
1891
|
for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'Linear ready issue canonical discovery')) {
|
|
@@ -1800,6 +1911,10 @@ export class FactoryLoop {
|
|
|
1800
1911
|
}
|
|
1801
1912
|
async #readIssue(path) {
|
|
1802
1913
|
try {
|
|
1914
|
+
if (isGithubIssueFilePath(path)) {
|
|
1915
|
+
const githubIssue = await this.#readGithubIssue(path);
|
|
1916
|
+
return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
|
|
1917
|
+
}
|
|
1803
1918
|
// Newly-synced issues land as a change-event STUB at the primary
|
|
1804
1919
|
// /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
|
|
1805
1920
|
// record lands at the by-id / by-uuid aliases. Read the canonical sibling
|
|
@@ -2525,9 +2640,15 @@ export class FactoryLoop {
|
|
|
2525
2640
|
}
|
|
2526
2641
|
this.#postMergeDoneAdvances.add(advanceKey);
|
|
2527
2642
|
try {
|
|
2528
|
-
const
|
|
2529
|
-
|
|
2530
|
-
|
|
2643
|
+
const githubIssue = isGithubIssue(issue);
|
|
2644
|
+
if (githubIssue) {
|
|
2645
|
+
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
|
|
2646
|
+
}
|
|
2647
|
+
else {
|
|
2648
|
+
const doneStateId = this.#states.idFor(issue.team, 'done');
|
|
2649
|
+
await this.#linear.setState(issue, doneStateId);
|
|
2650
|
+
await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId });
|
|
2651
|
+
}
|
|
2531
2652
|
this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
|
|
2532
2653
|
this.#increment('mergedPrAdvancedDone');
|
|
2533
2654
|
this.#increment('done');
|
|
@@ -2540,11 +2661,12 @@ export class FactoryLoop {
|
|
|
2540
2661
|
try {
|
|
2541
2662
|
const channel = await this.#slackChannelDir();
|
|
2542
2663
|
if (channel) {
|
|
2664
|
+
const systemOfRecord = githubIssue ? 'GitHub issue closed' : 'Linear state set to Done';
|
|
2543
2665
|
const root = await this.#slack.postThread({
|
|
2544
2666
|
channel,
|
|
2545
|
-
text: `${issue.key}: PR merged;
|
|
2667
|
+
text: `${issue.key}: PR merged; ${systemOfRecord}.`,
|
|
2546
2668
|
});
|
|
2547
|
-
await this.#slack.reply(root.threadId, `${issue.key}:
|
|
2669
|
+
await this.#slack.reply(root.threadId, `${issue.key}: ${systemOfRecord}.`);
|
|
2548
2670
|
this.#recordSlackWritebackSuccess('merge-done-thread');
|
|
2549
2671
|
}
|
|
2550
2672
|
}
|
|
@@ -2571,15 +2693,19 @@ export class FactoryLoop {
|
|
|
2571
2693
|
// This no-record path runs after agents are released, so there is no
|
|
2572
2694
|
// tracked PR identity left. Keep the scan simple and prefer branch identity
|
|
2573
2695
|
// over title/body references to avoid "related to AR-N" body false positives.
|
|
2574
|
-
|
|
2575
|
-
|
|
2696
|
+
const githubSource = await this.#issueSource() === 'github';
|
|
2697
|
+
const paths = githubSource ? await this.#githubIssuePaths() : await this.#mount.listTree(ISSUE_ROOT);
|
|
2698
|
+
for (const path of paths) {
|
|
2699
|
+
if (githubSource ? !isGithubIssueFilePath(path) : !isIssueFilePath(path)) {
|
|
2576
2700
|
continue;
|
|
2577
2701
|
}
|
|
2578
2702
|
const issue = await this.#readIssue(path);
|
|
2579
|
-
if (!issue ||
|
|
2703
|
+
if (!issue || (githubSource
|
|
2704
|
+
? issue.state?.name?.trim().toLowerCase() === 'closed'
|
|
2705
|
+
: !isUpstreamState(issue.stateId))) {
|
|
2580
2706
|
continue;
|
|
2581
2707
|
}
|
|
2582
|
-
if (!
|
|
2708
|
+
if (!isDispatchableIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
|
|
2583
2709
|
continue;
|
|
2584
2710
|
}
|
|
2585
2711
|
const score = prSnapshotIssueMatchScore(snapshot, issue.key);
|
|
@@ -2587,7 +2713,7 @@ export class FactoryLoop {
|
|
|
2587
2713
|
best = { issue, score };
|
|
2588
2714
|
}
|
|
2589
2715
|
}
|
|
2590
|
-
this.#logger.debug?.(
|
|
2716
|
+
this.#logger.debug?.(`[factory] scanned ${githubSource ? 'GitHub' : 'Linear'} issues for merged PR advance`, {
|
|
2591
2717
|
prNumber: snapshot.number,
|
|
2592
2718
|
durationMs: this.#clock.now() - scanStartedAtMs,
|
|
2593
2719
|
matchedIssue: best?.issue.key,
|
|
@@ -2752,6 +2878,28 @@ export class FactoryLoop {
|
|
|
2752
2878
|
}
|
|
2753
2879
|
return found;
|
|
2754
2880
|
}
|
|
2881
|
+
async #githubPrObservedMerged(record, issue) {
|
|
2882
|
+
const babysatSnapshot = await this.#readBabysatPrSnapshot(record);
|
|
2883
|
+
if (babysatSnapshot && prMetaShowsMerged(babysatSnapshot)) {
|
|
2884
|
+
return true;
|
|
2885
|
+
}
|
|
2886
|
+
const pr = this.#babysitterPr.get(record.issue.key) ?? await this.#completionPrForIssue(issue);
|
|
2887
|
+
if (!pr) {
|
|
2888
|
+
return false;
|
|
2889
|
+
}
|
|
2890
|
+
for (const path of await this.#pullMetaPathsFor(pr.repo, pr.prNumber)) {
|
|
2891
|
+
try {
|
|
2892
|
+
const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, pr.prNumber);
|
|
2893
|
+
if (snapshot && prMetaShowsMerged(snapshot)) {
|
|
2894
|
+
return true;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
catch {
|
|
2898
|
+
// A concurrent mount refresh may remove an alternate path; keep scanning.
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
return false;
|
|
2902
|
+
}
|
|
2755
2903
|
async #completeIssue(record, opts = {}) {
|
|
2756
2904
|
const completionKey = issueKey(record.issue);
|
|
2757
2905
|
if (this.#completionInFlight.has(completionKey)) {
|
|
@@ -2766,16 +2914,44 @@ export class FactoryLoop {
|
|
|
2766
2914
|
// (the legacy terminal) so the issue never gets stuck on an unconfigured
|
|
2767
2915
|
// state.
|
|
2768
2916
|
const issueTeam = issue?.team;
|
|
2769
|
-
const
|
|
2917
|
+
const githubIssue = issue ? isGithubIssue(issue) : false;
|
|
2918
|
+
const configuredHumanReview = opts.targetState !== 'done' &&
|
|
2770
2919
|
this.#config.terminalState === 'human-review' &&
|
|
2771
|
-
this.#states.hasHumanReview(issueTeam);
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2920
|
+
(githubIssue || this.#states.hasHumanReview(issueTeam));
|
|
2921
|
+
let githubMerged = githubIssue && opts.completionReason === 'pr-merged';
|
|
2922
|
+
if (issue && githubIssue && !configuredHumanReview && !githubMerged) {
|
|
2923
|
+
githubMerged = await this.#githubPrObservedMerged(record, issue);
|
|
2924
|
+
if (!githubMerged && opts.runMergeGate !== false) {
|
|
2925
|
+
const mergeCommandAccepted = await this.#runCompletionMergeGate(issue);
|
|
2926
|
+
// A successful merge command can mean queued/auto-merge rather than
|
|
2927
|
+
// merged. Only mounted PR state or a merged webhook may prove merge.
|
|
2928
|
+
if (mergeCommandAccepted) {
|
|
2929
|
+
githubMerged = await this.#githubPrObservedMerged(record, issue);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
// A GitHub issue only closes after its PR merges. If a configured done
|
|
2934
|
+
// path cannot merge (including mergePolicy: never), park it for a human
|
|
2935
|
+
// instead of closing the issue while its PR remains open.
|
|
2936
|
+
const humanReview = configuredHumanReview || (githubIssue && !githubMerged);
|
|
2775
2937
|
const statusLabel = humanReview ? 'In Human Review' : 'Done';
|
|
2776
2938
|
if (issue) {
|
|
2777
|
-
|
|
2778
|
-
|
|
2939
|
+
if (githubIssue) {
|
|
2940
|
+
if (humanReview) {
|
|
2941
|
+
await this.#githubWriteback.setStatus(issue, 'human-review');
|
|
2942
|
+
await this.#githubWriteback.postComment(issue, `Factory agents completed; this issue is awaiting human review. The pull request remains open.\n\nMerge policy: ${this.#config.mergePolicy}`);
|
|
2943
|
+
}
|
|
2944
|
+
else {
|
|
2945
|
+
await this.#githubWriteback.closeIssue(issue, 'Factory observed the linked pull request merge and completed this issue.');
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
else {
|
|
2949
|
+
const targetState = humanReview
|
|
2950
|
+
? this.#states.idFor(issueTeam, 'humanReview')
|
|
2951
|
+
: this.#states.idFor(issueTeam, 'done');
|
|
2952
|
+
await this.#linear.setState(issue, targetState);
|
|
2953
|
+
await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
|
|
2954
|
+
}
|
|
2779
2955
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
2780
2956
|
}
|
|
2781
2957
|
if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) {
|
|
@@ -2783,14 +2959,15 @@ export class FactoryLoop {
|
|
|
2783
2959
|
const channel = await this.#slackChannelDir();
|
|
2784
2960
|
if (channel) {
|
|
2785
2961
|
const merged = opts.completionReason === 'pr-merged';
|
|
2962
|
+
const systemOfRecord = githubIssue ? 'GitHub status' : 'Linear state';
|
|
2786
2963
|
const completionText = merged
|
|
2787
|
-
? `${record.issue.key}: PR merged;
|
|
2964
|
+
? `${record.issue.key}: PR merged; ${systemOfRecord} set to ${statusLabel}.`
|
|
2788
2965
|
: `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
|
|
2789
2966
|
const stateText = merged
|
|
2790
|
-
? `${record.issue.key}:
|
|
2967
|
+
? `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`
|
|
2791
2968
|
: humanReview
|
|
2792
|
-
? `${record.issue.key}: awaiting human review;
|
|
2793
|
-
: `${record.issue.key}:
|
|
2969
|
+
? `${record.issue.key}: awaiting human review; ${systemOfRecord} set to ${statusLabel}.`
|
|
2970
|
+
: `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`;
|
|
2794
2971
|
const root = await this.#slack.postThread({
|
|
2795
2972
|
channel,
|
|
2796
2973
|
text: completionText,
|
|
@@ -2806,7 +2983,7 @@ export class FactoryLoop {
|
|
|
2806
2983
|
// Only auto-merge on the `done` terminal path. Human Review parks the PR
|
|
2807
2984
|
// for an operator — the merge gate (which requires an APPROVED review)
|
|
2808
2985
|
// would refuse anyway, and we must not merge before the human has looked.
|
|
2809
|
-
if (issue && !humanReview && opts.runMergeGate !== false) {
|
|
2986
|
+
if (issue && !githubIssue && !humanReview && opts.runMergeGate !== false) {
|
|
2810
2987
|
await this.#runCompletionMergeGate(issue);
|
|
2811
2988
|
}
|
|
2812
2989
|
await this.#releaseAndTerminateAgents([...record.agents], humanReview ? 'issue-human-review' : 'issue-done', 'completion');
|
|
@@ -3373,11 +3550,11 @@ export class FactoryLoop {
|
|
|
3373
3550
|
}
|
|
3374
3551
|
async #handleTriageEscalationSlackAnswer(record, text) {
|
|
3375
3552
|
const issue = await this.#readIssue(record.issue.path);
|
|
3376
|
-
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !
|
|
3553
|
+
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
|
|
3377
3554
|
this.#increment('slackTriageAnswersIgnoredIssueUnavailable');
|
|
3378
3555
|
return;
|
|
3379
3556
|
}
|
|
3380
|
-
if (!this.#
|
|
3557
|
+
if (!this.#isIssueReady(issue)) {
|
|
3381
3558
|
this.#increment('slackTriageAnswersIgnoredIssueNotReady');
|
|
3382
3559
|
return;
|
|
3383
3560
|
}
|
|
@@ -3544,27 +3721,27 @@ export class FactoryLoop {
|
|
|
3544
3721
|
async #runCompletionMergeGate(issue) {
|
|
3545
3722
|
if (this.#isSyntheticProbeIssue(issue)) {
|
|
3546
3723
|
await this.#closeSyntheticProbeIfPresent(issue);
|
|
3547
|
-
return;
|
|
3724
|
+
return false;
|
|
3548
3725
|
}
|
|
3549
|
-
if (!
|
|
3726
|
+
if (!isDispatchableIssue(issue)) {
|
|
3550
3727
|
this.#logger.warn?.('[factory] merge gate skipped non-real Linear issue', { issue: issue.key });
|
|
3551
3728
|
this.#increment('mergeGateSkippedNonReal');
|
|
3552
|
-
return;
|
|
3729
|
+
return false;
|
|
3553
3730
|
}
|
|
3554
3731
|
if (this.#config.mergePolicy !== 'on-green-with-review') {
|
|
3555
|
-
return;
|
|
3732
|
+
return false;
|
|
3556
3733
|
}
|
|
3557
3734
|
const pr = await this.#probePrResolver(issue);
|
|
3558
3735
|
if (!pr) {
|
|
3559
3736
|
this.#logger.warn?.('[factory] merge gate found no PR for real issue', { issue: issue.key });
|
|
3560
3737
|
this.#increment('mergeGateMissingPr');
|
|
3561
|
-
return;
|
|
3738
|
+
return false;
|
|
3562
3739
|
}
|
|
3563
3740
|
const ready = await this.#waitForMergeReady(pr);
|
|
3564
3741
|
const headSha = ready?.live.headRefOid;
|
|
3565
3742
|
if (!ready || !headSha) {
|
|
3566
3743
|
this.#increment('mergeGateNotMerged');
|
|
3567
|
-
return;
|
|
3744
|
+
return false;
|
|
3568
3745
|
}
|
|
3569
3746
|
const result = await this.#mergeGate.merge({
|
|
3570
3747
|
repo: pr.repo,
|
|
@@ -3580,7 +3757,7 @@ export class FactoryLoop {
|
|
|
3580
3757
|
reason: result.reason,
|
|
3581
3758
|
});
|
|
3582
3759
|
this.#increment('mergeGateMergeAborted');
|
|
3583
|
-
return;
|
|
3760
|
+
return false;
|
|
3584
3761
|
}
|
|
3585
3762
|
this.#logger.info?.('[factory] merge gate merged PR', {
|
|
3586
3763
|
issue: issue.key,
|
|
@@ -3589,6 +3766,7 @@ export class FactoryLoop {
|
|
|
3589
3766
|
headSha,
|
|
3590
3767
|
});
|
|
3591
3768
|
this.#increment('mergeGateMerged');
|
|
3769
|
+
return true;
|
|
3592
3770
|
}
|
|
3593
3771
|
async #closeSyntheticProbeIfPresent(issue) {
|
|
3594
3772
|
const probe = this.#customProbePrResolver
|
|
@@ -3732,6 +3910,48 @@ export function parseGithubIssue(path, content) {
|
|
|
3732
3910
|
raw: asRecord(parsed) ?? payload,
|
|
3733
3911
|
};
|
|
3734
3912
|
}
|
|
3913
|
+
export function parseGithubFactoryIssue(path, content) {
|
|
3914
|
+
return githubIssueAsFactoryIssue(parseGithubIssue(path, content));
|
|
3915
|
+
}
|
|
3916
|
+
const githubIssueAsFactoryIssue = (issue) => {
|
|
3917
|
+
const wrapper = asRecord(issue.raw) ?? {};
|
|
3918
|
+
const payload = wrappedPayload(wrapper);
|
|
3919
|
+
const objectId = stringValue(wrapper.objectId);
|
|
3920
|
+
const stableId = `${issue.repo}#${objectId ?? issue.number}`;
|
|
3921
|
+
return {
|
|
3922
|
+
uuid: stableId,
|
|
3923
|
+
key: String(issue.number),
|
|
3924
|
+
title: issue.title,
|
|
3925
|
+
description: issue.body,
|
|
3926
|
+
stateId: '',
|
|
3927
|
+
state: issue.state ? { name: issue.state } : undefined,
|
|
3928
|
+
labels: issue.labels,
|
|
3929
|
+
path: issue.path,
|
|
3930
|
+
raw: {
|
|
3931
|
+
...wrapper,
|
|
3932
|
+
provider: 'github',
|
|
3933
|
+
objectType: 'issue',
|
|
3934
|
+
objectId: stableId,
|
|
3935
|
+
payload: {
|
|
3936
|
+
...payload,
|
|
3937
|
+
id: stableId,
|
|
3938
|
+
number: issue.number,
|
|
3939
|
+
title: issue.title,
|
|
3940
|
+
description: issue.body,
|
|
3941
|
+
url: issue.url,
|
|
3942
|
+
source: {
|
|
3943
|
+
provider: 'github',
|
|
3944
|
+
id: stableId,
|
|
3945
|
+
owner: issue.owner,
|
|
3946
|
+
repo: issue.repoName,
|
|
3947
|
+
number: issue.number,
|
|
3948
|
+
url: issue.url,
|
|
3949
|
+
path: issue.path,
|
|
3950
|
+
},
|
|
3951
|
+
},
|
|
3952
|
+
},
|
|
3953
|
+
};
|
|
3954
|
+
};
|
|
3735
3955
|
export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
|
|
3736
3956
|
try {
|
|
3737
3957
|
return parseJsonContent(await readFile(path, 'utf8'));
|
|
@@ -3764,6 +3984,27 @@ export function isRealLinearIssue(issue) {
|
|
|
3764
3984
|
typeof payload.url === 'string' &&
|
|
3765
3985
|
payload.url.length > 0;
|
|
3766
3986
|
}
|
|
3987
|
+
export function isDispatchableIssue(issue) {
|
|
3988
|
+
if (isRealLinearIssue(issue)) {
|
|
3989
|
+
return true;
|
|
3990
|
+
}
|
|
3991
|
+
if (!isGithubIssue(issue)) {
|
|
3992
|
+
return false;
|
|
3993
|
+
}
|
|
3994
|
+
const payload = wrappedPayload(issue.raw);
|
|
3995
|
+
const source = asRecord(payload.source);
|
|
3996
|
+
const sourceId = stringValue(source?.id);
|
|
3997
|
+
const number = numberValue(source?.number) ?? numberValue(payload.number);
|
|
3998
|
+
const url = stringValue(source?.url) ?? stringValue(payload.url);
|
|
3999
|
+
const hasStableIdentity = Boolean(sourceId?.trim()) || (Number.isInteger(number) && (number ?? 0) > 0);
|
|
4000
|
+
return Boolean(issue.uuid.trim() && issue.key.trim() && hasStableIdentity && url?.trim());
|
|
4001
|
+
}
|
|
4002
|
+
const isGithubIssue = (issue) => {
|
|
4003
|
+
const wrapper = asRecord(issue.raw);
|
|
4004
|
+
const source = asRecord(wrappedPayload(issue.raw).source);
|
|
4005
|
+
return stringValue(source?.provider)?.toLowerCase() === 'github' &&
|
|
4006
|
+
(stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
|
|
4007
|
+
};
|
|
3767
4008
|
const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
|
|
3768
4009
|
const pidsFromSpawnResult = (result) => {
|
|
3769
4010
|
const pids = new Set();
|
|
@@ -3943,7 +4184,10 @@ function githubUrlCandidates(value) {
|
|
|
3943
4184
|
return [...candidates];
|
|
3944
4185
|
}
|
|
3945
4186
|
function labelRoutesForIssue(issue, config) {
|
|
3946
|
-
const
|
|
4187
|
+
const githubReadinessLabel = isGithubIssue(issue) ? config.safety.requireLabel.trim().toLowerCase() : undefined;
|
|
4188
|
+
const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label) &&
|
|
4189
|
+
label.toLowerCase() !== githubReadinessLabel &&
|
|
4190
|
+
(!isGithubIssue(issue) || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
|
|
3947
4191
|
const routes = [];
|
|
3948
4192
|
const offendingLabels = [];
|
|
3949
4193
|
const seenRepos = new Set();
|
|
@@ -4171,7 +4415,7 @@ const githubIssueDirectoryPathParts = (path) => {
|
|
|
4171
4415
|
slug: match[6],
|
|
4172
4416
|
};
|
|
4173
4417
|
};
|
|
4174
|
-
const githubIssueHasFactoryLabel = (issue) => issue.labels.some((label) => label.toLowerCase() ===
|
|
4418
|
+
const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL) => issue.labels.some((label) => label.trim().toLowerCase() === requiredLabel.trim().toLowerCase());
|
|
4175
4419
|
const githubIssueIsClosed = (issue) => issue.state === 'closed';
|
|
4176
4420
|
const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
|
|
4177
4421
|
const teamId = config.linear.teamIds[config.safety.requireTeamKey];
|