@agent-relay/factory 0.1.80 → 0.1.81
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 +39 -5
- package/dist/cli/fleet.d.ts +8 -2
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +40 -4
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +9 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +14 -3
- package/dist/config/schema.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +50 -12
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/github/gh-identity.d.ts +44 -0
- package/dist/github/gh-identity.d.ts.map +1 -0
- package/dist/github/gh-identity.js +31 -0
- package/dist/github/gh-identity.js.map +1 -0
- package/dist/github/index.d.ts +3 -1
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +2 -1
- package/dist/github/index.js.map +1 -1
- package/dist/github/merge-gate.d.ts +22 -1
- package/dist/github/merge-gate.d.ts.map +1 -1
- package/dist/github/merge-gate.js +103 -24
- package/dist/github/merge-gate.js.map +1 -1
- package/dist/github/probe-closer.d.ts +4 -3
- package/dist/github/probe-closer.d.ts.map +1 -1
- package/dist/github/probe-closer.js +37 -28
- package/dist/github/probe-closer.js.map +1 -1
- package/dist/github/standalone-babysitter.d.ts +7 -3
- package/dist/github/standalone-babysitter.d.ts.map +1 -1
- package/dist/github/standalone-babysitter.js +16 -46
- package/dist/github/standalone-babysitter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/intake/notion.d.ts +36 -6
- package/dist/intake/notion.d.ts.map +1 -1
- package/dist/intake/notion.js +74 -16
- package/dist/intake/notion.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +14 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +77 -211
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/mount.d.ts +1 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import { DEFAULT_DISCOVERY_SWEEP_BUDGET_MS, DEFAULT_READINESS_RECONCILE_TIMEOUT_
|
|
|
6
6
|
import { DEFAULT_RELAYFILE_OPERATION_TIMEOUT_MS, RelayfileOperationTimeoutError, relayfileTimeoutWithPhase, withRelayfileCallDeadline, } from '../mount/relayfile-operation-timeout.js';
|
|
7
7
|
import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
|
|
8
8
|
import { stateResolutionFromIds } from '../linear/state-resolver.js';
|
|
9
|
-
import {
|
|
9
|
+
import { GhCliGithubMergeGate, MountedGithubMergeGate, closeProbePr } from '../github/index.js';
|
|
10
10
|
import { factoryGithubIssueCommentDraftName, isFactoryGithubIssueCommentDraftName, isFactoryGithubOperationDraftName, } from '../github/writeback-paths.js';
|
|
11
11
|
import { VerificationPipeline } from '../environments/verification-pipeline.js';
|
|
12
12
|
import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
|
|
@@ -64,8 +64,7 @@ const LIVE_EVENT_DRAIN_BATCH_SIZE = 5;
|
|
|
64
64
|
const COMPLETION_SWEEP_INTERVAL_MS = 15_000;
|
|
65
65
|
const COMPLETION_SWEEP_BATCH_SIZE = 2;
|
|
66
66
|
const PREVIEW_SWEEP_INTERVAL_MS = 60_000;
|
|
67
|
-
const
|
|
68
|
-
const PROBE_PR_GH_CANDIDATE_LIMIT = 200;
|
|
67
|
+
const PROBE_PR_RESOLVED_CACHE_MS = 60_000;
|
|
69
68
|
const PUBLISHED_PR_CONFIRM_ATTEMPTS = 20;
|
|
70
69
|
const PUBLISHED_PR_CONFIRM_DELAY_MS = 100;
|
|
71
70
|
const SLACK_REPLY_EVENTS_LIMIT = 100;
|
|
@@ -119,8 +118,8 @@ const BABYSITTER_PR_SNAPSHOT_DEAD_LETTER_LIMIT = 256;
|
|
|
119
118
|
const BABYSITTER_PR_SNAPSHOT_DRAIN_PER_SWEEP = 16;
|
|
120
119
|
// A durably faulted mount would otherwise log an error per path per sweep.
|
|
121
120
|
const BABYSITTER_PR_SNAPSHOT_ESCALATED_LOG_EVERY = 20;
|
|
122
|
-
// Adoption probes an issue's open PR through the
|
|
123
|
-
//
|
|
121
|
+
// Adoption probes an issue's open PR through the mounted projection, so its
|
|
122
|
+
// full-tree fallback runs on a slower cadence than the completion sweep.
|
|
124
123
|
const BABYSITTER_ORPHAN_SWEEP_INTERVAL_MS = 60_000;
|
|
125
124
|
// Warn once per PR identity that arrives unowned, then fall back to debug. The
|
|
126
125
|
// counter carries the true volume; the log only has to make it discoverable.
|
|
@@ -457,10 +456,9 @@ export class FactoryLoop {
|
|
|
457
456
|
#mergeGate;
|
|
458
457
|
#verificationGate;
|
|
459
458
|
#probeCloser;
|
|
459
|
+
#defaultProbeCloser;
|
|
460
460
|
#probePrResolver;
|
|
461
461
|
#customProbePrResolver;
|
|
462
|
-
#hasProbePrGhRunner;
|
|
463
|
-
#probePrGhRunner;
|
|
464
462
|
#logger;
|
|
465
463
|
#clock;
|
|
466
464
|
#processIdentityReader;
|
|
@@ -787,7 +785,6 @@ export class FactoryLoop {
|
|
|
787
785
|
#publishedPullRequests = new Map();
|
|
788
786
|
#previewReferences = new Map();
|
|
789
787
|
#removedPreviewIds = new Set();
|
|
790
|
-
#probePrGhBackoffUntilMs = new Map();
|
|
791
788
|
#probePrResolvedCache = new Map();
|
|
792
789
|
// GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion
|
|
793
790
|
// cycles read the mirror directly instead of re-scanning all Linear issues.
|
|
@@ -880,7 +877,7 @@ export class FactoryLoop {
|
|
|
880
877
|
this.#githubWriteback = ports.githubWriteback ?? defaultGithubWriteback(config, ports.mount);
|
|
881
878
|
this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
|
|
882
879
|
this.#github = ports.github ?? MountGithubRead(ports.mount);
|
|
883
|
-
this.#mergeGate = ports.mergeGate ??
|
|
880
|
+
this.#mergeGate = ports.mergeGate ?? defaultMergeGate(config, ports.mount);
|
|
884
881
|
this.#verificationGate = ports.verificationGate ?? (config.verification.enabled
|
|
885
882
|
? new VerificationPipeline({
|
|
886
883
|
descriptorPath: config.verification.descriptorPath,
|
|
@@ -892,10 +889,9 @@ export class FactoryLoop {
|
|
|
892
889
|
maxTeardownTimeoutMs: config.verification.maxTeardownTimeoutMs,
|
|
893
890
|
})
|
|
894
891
|
: undefined);
|
|
892
|
+
this.#defaultProbeCloser = !ports.probeCloser;
|
|
895
893
|
this.#probeCloser = ports.probeCloser ?? closeProbePr;
|
|
896
894
|
this.#customProbePrResolver = Boolean(ports.probePrResolver);
|
|
897
|
-
this.#hasProbePrGhRunner = Boolean(ports.probePrGhRunner);
|
|
898
|
-
this.#probePrGhRunner = ports.probePrGhRunner ?? failClosedGhRunner;
|
|
899
895
|
this.#probePrResolver = ports.probePrResolver ??
|
|
900
896
|
((issue) => this.#resolveIssuePr(issue, { repo: this.#probeRepoForIssue(issue) }));
|
|
901
897
|
this.#logger = normalizeLogger(ports.logger ?? console);
|
|
@@ -2421,11 +2417,6 @@ export class FactoryLoop {
|
|
|
2421
2417
|
}
|
|
2422
2418
|
if (pr.draft) {
|
|
2423
2419
|
this.#increment('completionSweepDraftPr');
|
|
2424
|
-
// Must match the key `#completionPrForIssue` resolves under —
|
|
2425
|
-
// same options, same repo scope — or this backoff is written
|
|
2426
|
-
// under a name nothing reads and the draft PR is re-fetched from
|
|
2427
|
-
// gh on every pass.
|
|
2428
|
-
this.#probePrGhBackoffUntilMs.set(this.#probePrCacheKey(issue, { repo: this.#probeRepoForIssue(issue) }), this.#clock.now() + PROBE_PR_GH_BACKOFF_MS);
|
|
2429
2420
|
return undefined;
|
|
2430
2421
|
}
|
|
2431
2422
|
if (record.decision.implementers.length > 1 && !await this.#allImplementersHaveCompletionPr(record)) {
|
|
@@ -2557,9 +2548,7 @@ export class FactoryLoop {
|
|
|
2557
2548
|
}
|
|
2558
2549
|
}
|
|
2559
2550
|
// Candidate PR meta paths for an ownerless record, newest PR first. Resolution
|
|
2560
|
-
// reuses the durable dispatch receipts and then the probe resolver
|
|
2561
|
-
// carries its own gh backoff, so a record whose implementer has not opened a
|
|
2562
|
-
// PR yet costs no more than a cached miss).
|
|
2551
|
+
// reuses the durable dispatch receipts and then the mounted probe resolver.
|
|
2563
2552
|
async #orphanedPrMetaPaths(record) {
|
|
2564
2553
|
const wanted = [];
|
|
2565
2554
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
@@ -2708,19 +2697,13 @@ export class FactoryLoop {
|
|
|
2708
2697
|
return dependencyRepoForIssue(issue, undefined, this.#config, { allowDefault: false });
|
|
2709
2698
|
}
|
|
2710
2699
|
/**
|
|
2711
|
-
* The key for
|
|
2712
|
-
*
|
|
2713
|
-
* Shared rather than inlined because those two maps are written from more than
|
|
2714
|
-
* one place: `#resolveIssuePr` writes both, and the completion sweep writes a
|
|
2715
|
-
* draft-PR backoff directly. Those writes must agree on the key or the backoff
|
|
2716
|
-
* is set under a name nothing reads, which silently costs a gh call per pass.
|
|
2700
|
+
* The key for `#probePrResolvedCache`.
|
|
2717
2701
|
*
|
|
2718
2702
|
* `repo` is part of the key because it narrows which pull requests the
|
|
2719
2703
|
* resolution can even see — an issue whose route changes must not be served
|
|
2720
|
-
* the previous repository's PR,
|
|
2721
|
-
*
|
|
2722
|
-
*
|
|
2723
|
-
* must not share an entry with any single-repo one.
|
|
2704
|
+
* the previous repository's PR, because the completion path probes and
|
|
2705
|
+
* CLOSES what this returns. `*` marks the unscoped walk, a genuinely
|
|
2706
|
+
* different resolution that must not share an entry with any single-repo one.
|
|
2724
2707
|
*
|
|
2725
2708
|
* Every dimension is a trailing `:`-prefixed segment so the completion
|
|
2726
2709
|
* invalidation — which clears `stateKey` plus everything starting
|
|
@@ -2745,31 +2728,13 @@ export class FactoryLoop {
|
|
|
2745
2728
|
// method. The cache had a reader and no writer on the hot path, so the
|
|
2746
2729
|
// full tree walk repeated for every caller, on every sweep, forever.
|
|
2747
2730
|
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
// property of the PR (its state is about to flip and the caller wants to
|
|
2751
|
-
// see that promptly), not of which resolver observed it.
|
|
2731
|
+
// Keep drafts uncached because their state is expected to flip and callers
|
|
2732
|
+
// need to observe that promptly.
|
|
2752
2733
|
if (!mountPr.draft) {
|
|
2753
|
-
this.#probePrResolvedCache.set(key, { pr: mountPr, expiresAtMs: now +
|
|
2734
|
+
this.#probePrResolvedCache.set(key, { pr: mountPr, expiresAtMs: now + PROBE_PR_RESOLVED_CACHE_MS });
|
|
2754
2735
|
}
|
|
2755
2736
|
return mountPr;
|
|
2756
2737
|
}
|
|
2757
|
-
const backoffUntil = this.#probePrGhBackoffUntilMs.get(key) ?? 0;
|
|
2758
|
-
if (backoffUntil > now) {
|
|
2759
|
-
this.#increment('probePrGhBackoffSkips');
|
|
2760
|
-
return undefined;
|
|
2761
|
-
}
|
|
2762
|
-
const ghPr = await resolveIssuePrFromGh(this.#probePrGhRunner, this.#config, issue, opts, this.#logger);
|
|
2763
|
-
this.#increment('probePrGhResolveAttempts');
|
|
2764
|
-
if (ghPr) {
|
|
2765
|
-
this.#probePrGhBackoffUntilMs.delete(key);
|
|
2766
|
-
if (!ghPr.draft) {
|
|
2767
|
-
this.#probePrResolvedCache.set(key, { pr: ghPr, expiresAtMs: now + PROBE_PR_GH_BACKOFF_MS });
|
|
2768
|
-
}
|
|
2769
|
-
this.#increment('probePrGhResolveHits');
|
|
2770
|
-
return ghPr;
|
|
2771
|
-
}
|
|
2772
|
-
this.#probePrGhBackoffUntilMs.set(key, now + PROBE_PR_GH_BACKOFF_MS);
|
|
2773
2738
|
return undefined;
|
|
2774
2739
|
}
|
|
2775
2740
|
async runOnce(opts = {}) {
|
|
@@ -8223,18 +8188,18 @@ export class FactoryLoop {
|
|
|
8223
8188
|
const roots = githubIssueRepoRoots(owner, repo);
|
|
8224
8189
|
const cachedBatches = roots.map((root) => this.#cachedDiscoveryTree(root));
|
|
8225
8190
|
const allRootsCached = cachedBatches.every((paths) => paths !== undefined);
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8191
|
+
// The issue index is the current eligibility authority, while the
|
|
8192
|
+
// durable trees are only an incremental fallback for mounts whose
|
|
8193
|
+
// index is absent or malformed. A checkpoint can legitimately retain
|
|
8194
|
+
// stale membership (for example across an older cache format or a
|
|
8195
|
+
// missed change window); letting two cached roots bypass a healthy
|
|
8196
|
+
// index makes that stale snapshot self-validating forever.
|
|
8197
|
+
const indexedPaths = await this.#githubIssuePathsFromIndex(owner, repo);
|
|
8229
8198
|
// Keep the fallback roots as separate batches. Flattening a very large
|
|
8230
8199
|
// provider result is synchronous work and can starve the durable loop
|
|
8231
8200
|
// heartbeat before the bounded scan below gets a chance to yield.
|
|
8232
8201
|
let pathBatches;
|
|
8233
|
-
if (
|
|
8234
|
-
pathBatches = cachedBatches;
|
|
8235
|
-
this.#increment('githubIssueDiscoveryCacheReposUsed');
|
|
8236
|
-
}
|
|
8237
|
-
else if (indexedPaths) {
|
|
8202
|
+
if (indexedPaths) {
|
|
8238
8203
|
// Do not feed this into the discovery cache: the index only covers
|
|
8239
8204
|
// open, labeled issues, so it is a filtered subset of the real
|
|
8240
8205
|
// tree (and an empty result for whichever root form the index
|
|
@@ -8244,6 +8209,10 @@ export class FactoryLoop {
|
|
|
8244
8209
|
pathBatches = [indexedPaths];
|
|
8245
8210
|
this.#increment('githubIssueIndexReposUsed');
|
|
8246
8211
|
}
|
|
8212
|
+
else if (allRootsCached) {
|
|
8213
|
+
pathBatches = cachedBatches;
|
|
8214
|
+
this.#increment('githubIssueDiscoveryCacheReposUsed');
|
|
8215
|
+
}
|
|
8247
8216
|
else {
|
|
8248
8217
|
pathBatches = [];
|
|
8249
8218
|
for (const root of roots) {
|
|
@@ -8781,9 +8750,9 @@ export class FactoryLoop {
|
|
|
8781
8750
|
const repo = dependencyRepoForIssue(issue, undefined, this.#config);
|
|
8782
8751
|
if (!repo)
|
|
8783
8752
|
return false;
|
|
8784
|
-
// This probe does
|
|
8785
|
-
//
|
|
8786
|
-
//
|
|
8753
|
+
// This specialized probe does not go through `#resolveIssuePr`, so it never
|
|
8754
|
+
// saw that method's cache, and `#terminalDependencyIdentities` only ever
|
|
8755
|
+
// memoises the TRUE answer. A dependency that is not merged was
|
|
8787
8756
|
// therefore re-walked in full for every issue declaring it, on every sweep;
|
|
8788
8757
|
// several issues blocked on one dependency multiplied a single tree walk by
|
|
8789
8758
|
// the number of blocked issues. Memoise the negative answer too, on exactly
|
|
@@ -10561,44 +10530,6 @@ export class FactoryLoop {
|
|
|
10561
10530
|
return true;
|
|
10562
10531
|
}
|
|
10563
10532
|
async #openPullRequestByHead(repo, expectedHeadRef) {
|
|
10564
|
-
if (this.#hasProbePrGhRunner) {
|
|
10565
|
-
try {
|
|
10566
|
-
const result = await this.#probePrGhRunner([
|
|
10567
|
-
'pr',
|
|
10568
|
-
'list',
|
|
10569
|
-
'--repo',
|
|
10570
|
-
repo,
|
|
10571
|
-
'--head',
|
|
10572
|
-
expectedHeadRef,
|
|
10573
|
-
'--state',
|
|
10574
|
-
'open',
|
|
10575
|
-
'--json',
|
|
10576
|
-
'number,url,headRefName,isDraft',
|
|
10577
|
-
'--limit',
|
|
10578
|
-
'10',
|
|
10579
|
-
]);
|
|
10580
|
-
const payload = parseJsonContent(result.stdout);
|
|
10581
|
-
if (Array.isArray(payload)) {
|
|
10582
|
-
const candidates = payload.flatMap((entry) => {
|
|
10583
|
-
const candidate = asRecord(entry);
|
|
10584
|
-
const number = numberValue(candidate?.number);
|
|
10585
|
-
const url = stringValue(candidate?.url);
|
|
10586
|
-
const headRef = stringValue(candidate?.headRefName);
|
|
10587
|
-
if (!number || !url || headRef !== expectedHeadRef || candidate?.isDraft !== false)
|
|
10588
|
-
return [];
|
|
10589
|
-
return [{ repo, number, url, headRef }];
|
|
10590
|
-
});
|
|
10591
|
-
return candidates.sort((a, b) => b.number - a.number)[0];
|
|
10592
|
-
}
|
|
10593
|
-
}
|
|
10594
|
-
catch (error) {
|
|
10595
|
-
this.#logger.warn?.('[factory] exact-head gh PR lookup failed; falling back to mounted metadata', {
|
|
10596
|
-
repo,
|
|
10597
|
-
headRef: expectedHeadRef,
|
|
10598
|
-
error: describeError(error).errorMessage,
|
|
10599
|
-
});
|
|
10600
|
-
}
|
|
10601
|
-
}
|
|
10602
10533
|
const parts = githubRepoParts(repo);
|
|
10603
10534
|
if (!parts)
|
|
10604
10535
|
return undefined;
|
|
@@ -15370,11 +15301,6 @@ export class FactoryLoop {
|
|
|
15370
15301
|
this.#probePrResolvedCache.delete(cacheKey);
|
|
15371
15302
|
}
|
|
15372
15303
|
}
|
|
15373
|
-
for (const backoffKey of [...this.#probePrGhBackoffUntilMs.keys()]) {
|
|
15374
|
-
if (backoffKey === stateKey || backoffKey.startsWith(`${stateKey}:`)) {
|
|
15375
|
-
this.#probePrGhBackoffUntilMs.delete(backoffKey);
|
|
15376
|
-
}
|
|
15377
|
-
}
|
|
15378
15304
|
// Cancellation must see the subscription identity so it can issue the
|
|
15379
15305
|
// idempotent Relayfile DELETE before clearing the local owner maps.
|
|
15380
15306
|
await this.#cancelBabysittersForIssue(record.issue);
|
|
@@ -18047,19 +17973,29 @@ export class FactoryLoop {
|
|
|
18047
17973
|
if (!probe) {
|
|
18048
17974
|
return;
|
|
18049
17975
|
}
|
|
18050
|
-
|
|
17976
|
+
const closeInput = {
|
|
18051
17977
|
repo: probe.repo,
|
|
18052
17978
|
prNumber: probe.prNumber,
|
|
18053
17979
|
expectedIssueKey: issue.key,
|
|
18054
17980
|
requireTitleMarker: false,
|
|
18055
|
-
|
|
18056
|
-
|
|
17981
|
+
};
|
|
17982
|
+
if (this.#defaultProbeCloser) {
|
|
17983
|
+
await closeProbePr({
|
|
17984
|
+
...closeInput,
|
|
17985
|
+
...(probe.path ? { path: probe.path } : {}),
|
|
17986
|
+
mount: this.#mount,
|
|
17987
|
+
...(this.#mount.githubWrite ? { githubWrite: this.#mount.githubWrite } : {}),
|
|
17988
|
+
});
|
|
17989
|
+
}
|
|
17990
|
+
else {
|
|
17991
|
+
await this.#probeCloser(closeInput);
|
|
17992
|
+
}
|
|
18057
17993
|
this.#increment('mergeGateSyntheticClosed');
|
|
18058
17994
|
}
|
|
18059
17995
|
async #waitForMergeReady(pr) {
|
|
18060
17996
|
let lastReason = 'not checked';
|
|
18061
17997
|
for (let attempt = 1; attempt <= MERGE_GATE_MAX_ATTEMPTS; attempt += 1) {
|
|
18062
|
-
const verdict = await this.#mergeGate.check({ repo: pr.repo, number: pr.prNumber });
|
|
17998
|
+
const verdict = await this.#mergeGate.check({ repo: pr.repo, number: pr.prNumber, ...(pr.path ? { path: pr.path } : {}) });
|
|
18063
17999
|
lastReason = verdict.reason;
|
|
18064
18000
|
if (verdict.ready && verdict.live.headRefOid) {
|
|
18065
18001
|
return verdict;
|
|
@@ -18080,14 +18016,44 @@ export class FactoryLoop {
|
|
|
18080
18016
|
return hasTitlePrefix(issue.title, FACTORY_E2E_MARKER);
|
|
18081
18017
|
}
|
|
18082
18018
|
}
|
|
18019
|
+
/**
|
|
18020
|
+
* The guarded merge is a GitHub mutation, so it answers to the same identity
|
|
18021
|
+
* policy as the lifecycle writeback. Under exact `github.identity: "app"` the
|
|
18022
|
+
* gate refuses the merge instead of squash-merging as the operator's local
|
|
18023
|
+
* `gh` user; `auto` and `user` keep today's behavior.
|
|
18024
|
+
*
|
|
18025
|
+
* Exported so the selection itself is testable rather than buried in the
|
|
18026
|
+
* constructor — the same shape as `defaultGithubWriteback` below. `run` is a
|
|
18027
|
+
* test seam only: production passes nothing and gets `defaultGhRunner`. No
|
|
18028
|
+
* test may reach the real `gh` binary here, because the operation it would
|
|
18029
|
+
* perform is an irreversible merge.
|
|
18030
|
+
*/
|
|
18031
|
+
export const defaultMergeGate = (config, mount, run) => new MountedGithubMergeGate(mount, new GhCliGithubMergeGate(run, config.github.identity));
|
|
18032
|
+
const hasAppGithubLifecycleWrite = (write) => Boolean(write?.postIssueComment &&
|
|
18033
|
+
write.ensureRepositoryLabel &&
|
|
18034
|
+
write.mutateIssueLabel &&
|
|
18035
|
+
write.updateIssue);
|
|
18083
18036
|
const defaultGithubWriteback = (config, mount) => {
|
|
18084
|
-
|
|
18037
|
+
const identity = config.github.identity;
|
|
18038
|
+
const cloudContainer = mount.writebackTransport === 'relayfile-cloud';
|
|
18039
|
+
if (identity === 'user') {
|
|
18040
|
+
if (cloudContainer) {
|
|
18041
|
+
throw new Error('GitHub identity "user" requires lifecycle writes through local gh, but the Factory cloud container does not contain gh. ' +
|
|
18042
|
+
'Use identity "auto" or "app" with a connected workspace GitHub App lifecycle write path.');
|
|
18043
|
+
}
|
|
18085
18044
|
return new GhCliGithubWriteback();
|
|
18086
18045
|
}
|
|
18087
|
-
if (
|
|
18046
|
+
if (hasAppGithubLifecycleWrite(mount.githubWrite)) {
|
|
18047
|
+
return new AppGithubWriteback(mount.githubWrite, mount.githubRead);
|
|
18048
|
+
}
|
|
18049
|
+
if (identity === 'app') {
|
|
18088
18050
|
throw new Error('GitHub identity "app" requires a connected workspace GitHub App lifecycle write path; refusing to fall back to the local gh user');
|
|
18089
18051
|
}
|
|
18090
|
-
|
|
18052
|
+
if (cloudContainer) {
|
|
18053
|
+
throw new Error('GitHub identity "auto" cannot select lifecycle writeback in the Factory cloud container: no connected workspace GitHub App lifecycle write path is available, ' +
|
|
18054
|
+
'and the container does not contain gh.');
|
|
18055
|
+
}
|
|
18056
|
+
return new GhCliGithubWriteback();
|
|
18091
18057
|
};
|
|
18092
18058
|
export function parseLinearIssue(path, content) {
|
|
18093
18059
|
const parsed = parseJsonContent(content);
|
|
@@ -19689,79 +19655,6 @@ export const resolveIssuePrFromMount = async (mount, config, issue, opts = {}, l
|
|
|
19689
19655
|
}
|
|
19690
19656
|
return resolved;
|
|
19691
19657
|
};
|
|
19692
|
-
const resolveIssuePrFromGh = async (run, config, issue, opts = {}, logger) => {
|
|
19693
|
-
const candidates = [];
|
|
19694
|
-
let lookupFailures = 0;
|
|
19695
|
-
for (const repo of reposFromConfig(config)) {
|
|
19696
|
-
let payload;
|
|
19697
|
-
try {
|
|
19698
|
-
const result = await run([
|
|
19699
|
-
'pr',
|
|
19700
|
-
'list',
|
|
19701
|
-
'--repo',
|
|
19702
|
-
repo,
|
|
19703
|
-
'--state',
|
|
19704
|
-
'all',
|
|
19705
|
-
'--json',
|
|
19706
|
-
'number,title,body,headRefName,headRepository,headRepositoryOwner,isCrossRepository,isDraft,state,url',
|
|
19707
|
-
'--limit',
|
|
19708
|
-
String(PROBE_PR_GH_CANDIDATE_LIMIT),
|
|
19709
|
-
]);
|
|
19710
|
-
if (!result.stdout.trim()) {
|
|
19711
|
-
lookupFailures += 1;
|
|
19712
|
-
logger?.warn?.('[factory] gh PR resolver returned empty output', { issue: issue.key, repo });
|
|
19713
|
-
continue;
|
|
19714
|
-
}
|
|
19715
|
-
payload = parseJsonContent(result.stdout);
|
|
19716
|
-
}
|
|
19717
|
-
catch (error) {
|
|
19718
|
-
lookupFailures += 1;
|
|
19719
|
-
logger?.warn?.('[factory] gh PR resolver failed', { issue: issue.key, repo, error });
|
|
19720
|
-
continue;
|
|
19721
|
-
}
|
|
19722
|
-
if (!Array.isArray(payload)) {
|
|
19723
|
-
lookupFailures += 1;
|
|
19724
|
-
logger?.warn?.('[factory] gh PR resolver returned non-array payload', { issue: issue.key, repo });
|
|
19725
|
-
continue;
|
|
19726
|
-
}
|
|
19727
|
-
if (payload.length >= PROBE_PR_GH_CANDIDATE_LIMIT) {
|
|
19728
|
-
logger?.warn?.('[factory] gh PR resolver hit candidate limit', { issue: issue.key, repo, limit: PROBE_PR_GH_CANDIDATE_LIMIT });
|
|
19729
|
-
if (opts.failOnLookupError)
|
|
19730
|
-
lookupFailures += 1;
|
|
19731
|
-
}
|
|
19732
|
-
for (const entry of payload) {
|
|
19733
|
-
const pr = ghProbePrCandidate(entry);
|
|
19734
|
-
if (!pr ||
|
|
19735
|
-
(!factoryBranchMatchesIssue(pr.headRef, issue.key) &&
|
|
19736
|
-
!(opts.allowLegacyGithubBranch && legacyGithubBranchMatchesIssue(pr.headRef, issue))))
|
|
19737
|
-
continue;
|
|
19738
|
-
if (opts.openOnly && normalizePrState(pr.state) !== 'OPEN')
|
|
19739
|
-
continue;
|
|
19740
|
-
const score = issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts);
|
|
19741
|
-
if (score <= 0)
|
|
19742
|
-
continue;
|
|
19743
|
-
candidates.push({
|
|
19744
|
-
repo,
|
|
19745
|
-
prNumber: pr.number,
|
|
19746
|
-
draft: pr.draft,
|
|
19747
|
-
headRef: pr.headRef,
|
|
19748
|
-
headRepo: pr.headRepo,
|
|
19749
|
-
crossRepository: pr.crossRepository ?? (pr.headRepo ? pr.headRepo.toLowerCase() !== repo.toLowerCase() : undefined),
|
|
19750
|
-
state: pr.state,
|
|
19751
|
-
url: pr.url,
|
|
19752
|
-
score,
|
|
19753
|
-
open: normalizePrState(pr.state) === 'OPEN',
|
|
19754
|
-
});
|
|
19755
|
-
}
|
|
19756
|
-
}
|
|
19757
|
-
const resolved = candidates.sort((a, b) => b.score - a.score ||
|
|
19758
|
-
Number(b.open) - Number(a.open) ||
|
|
19759
|
-
b.prNumber - a.prNumber)[0];
|
|
19760
|
-
if (!resolved && opts.failOnLookupError && lookupFailures > 0) {
|
|
19761
|
-
throw new Error(`Unable to confirm open pull request state for ${issue.key} in ${lookupFailures} configured repository lookup(s)`);
|
|
19762
|
-
}
|
|
19763
|
-
return resolved;
|
|
19764
|
-
};
|
|
19765
19658
|
const reposFromConfig = (config) => {
|
|
19766
19659
|
const repos = new Set([
|
|
19767
19660
|
...Object.values(config.repos.byLabel),
|
|
@@ -19880,32 +19773,6 @@ const readProbePrCandidate = async (mount, path) => {
|
|
|
19880
19773
|
return undefined;
|
|
19881
19774
|
}
|
|
19882
19775
|
};
|
|
19883
|
-
const ghProbePrCandidate = (value) => {
|
|
19884
|
-
const payload = asRecord(value);
|
|
19885
|
-
if (!payload)
|
|
19886
|
-
return undefined;
|
|
19887
|
-
const number = numberValue(payload.number);
|
|
19888
|
-
if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0)
|
|
19889
|
-
return undefined;
|
|
19890
|
-
const headRepository = asRecord(payload.headRepository);
|
|
19891
|
-
const headRepositoryOwner = asRecord(payload.headRepositoryOwner);
|
|
19892
|
-
const headRepo = githubRepositoryFullName(payload.headRepository) ?? (() => {
|
|
19893
|
-
const name = stringValue(headRepository?.name);
|
|
19894
|
-
const owner = stringValue(headRepositoryOwner?.login) ?? stringValue(headRepositoryOwner?.name);
|
|
19895
|
-
return name && owner ? `${owner}/${name}` : undefined;
|
|
19896
|
-
})();
|
|
19897
|
-
return {
|
|
19898
|
-
number,
|
|
19899
|
-
title: stringValue(payload.title) ?? '',
|
|
19900
|
-
body: stringValue(payload.body) ?? '',
|
|
19901
|
-
headRef: stringValue(payload.headRefName) ?? '',
|
|
19902
|
-
headRepo,
|
|
19903
|
-
crossRepository: booleanValue(payload.isCrossRepository),
|
|
19904
|
-
draft: booleanValue(payload.isDraft),
|
|
19905
|
-
state: stringValue(payload.state),
|
|
19906
|
-
url: stringValue(payload.url),
|
|
19907
|
-
};
|
|
19908
|
-
};
|
|
19909
19776
|
const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
|
|
19910
19777
|
if (opts.requireTitleMarker && !hasTitlePrefix(pr.title, marker))
|
|
19911
19778
|
return 0;
|
|
@@ -19948,7 +19815,6 @@ const legacyGithubPrCanBeAdopted = (issue, pr) => {
|
|
|
19948
19815
|
return pr.crossRepository === false || pr.headRepo?.toLowerCase() === pr.repo.toLowerCase();
|
|
19949
19816
|
};
|
|
19950
19817
|
const normalizePrState = (state) => state?.toUpperCase();
|
|
19951
|
-
const failClosedGhRunner = async () => ({ stdout: '[]' });
|
|
19952
19818
|
const ISSUE_KEY_PATTERN = /^[A-Z]+-\d+$/u;
|
|
19953
19819
|
const isIssuePathUnderRoot = (path) => path.startsWith(`${ISSUE_ROOT}/`) && path.endsWith('.json');
|
|
19954
19820
|
const isIssueFilePath = (path) => isIssuePathUnderRoot(path) &&
|