@agent-relay/factory 0.1.80 → 0.1.82

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.
Files changed (54) hide show
  1. package/README.md +39 -5
  2. package/dist/cli/fleet.d.ts +20 -2
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +46 -5
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +9 -0
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +14 -3
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/fleet/create-fleet.d.ts +13 -0
  11. package/dist/fleet/create-fleet.d.ts.map +1 -1
  12. package/dist/fleet/create-fleet.js +2 -0
  13. package/dist/fleet/create-fleet.js.map +1 -1
  14. package/dist/fleet/relay-fleet-client.d.ts +35 -0
  15. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  16. package/dist/fleet/relay-fleet-client.js +80 -15
  17. package/dist/fleet/relay-fleet-client.js.map +1 -1
  18. package/dist/github/gh-identity.d.ts +44 -0
  19. package/dist/github/gh-identity.d.ts.map +1 -0
  20. package/dist/github/gh-identity.js +31 -0
  21. package/dist/github/gh-identity.js.map +1 -0
  22. package/dist/github/index.d.ts +3 -1
  23. package/dist/github/index.d.ts.map +1 -1
  24. package/dist/github/index.js +2 -1
  25. package/dist/github/index.js.map +1 -1
  26. package/dist/github/merge-gate.d.ts +22 -1
  27. package/dist/github/merge-gate.d.ts.map +1 -1
  28. package/dist/github/merge-gate.js +103 -24
  29. package/dist/github/merge-gate.js.map +1 -1
  30. package/dist/github/probe-closer.d.ts +4 -3
  31. package/dist/github/probe-closer.d.ts.map +1 -1
  32. package/dist/github/probe-closer.js +37 -28
  33. package/dist/github/probe-closer.js.map +1 -1
  34. package/dist/github/standalone-babysitter.d.ts +7 -3
  35. package/dist/github/standalone-babysitter.d.ts.map +1 -1
  36. package/dist/github/standalone-babysitter.js +16 -46
  37. package/dist/github/standalone-babysitter.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/intake/notion.d.ts +36 -6
  43. package/dist/intake/notion.d.ts.map +1 -1
  44. package/dist/intake/notion.js +74 -16
  45. package/dist/intake/notion.js.map +1 -1
  46. package/dist/orchestrator/factory.d.ts +14 -0
  47. package/dist/orchestrator/factory.d.ts.map +1 -1
  48. package/dist/orchestrator/factory.js +77 -211
  49. package/dist/orchestrator/factory.js.map +1 -1
  50. package/dist/ports/mount.d.ts +1 -0
  51. package/dist/ports/mount.d.ts.map +1 -1
  52. package/dist/types.d.ts +1 -0
  53. package/dist/types.d.ts.map +1 -1
  54. 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 { GithubMergeGate, closeProbePr } from '../github/index.js';
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 PROBE_PR_GH_BACKOFF_MS = 60_000;
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 (already gh-backed-off) probe
123
- // resolver, so it runs on its own slower cadence than the completion sweep.
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 ?? new GithubMergeGate();
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 (which
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 BOTH `#probePrResolvedCache` and `#probePrGhBackoffUntilMs`.
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, or held off gh by the previous repository's
2721
- * negative backoff, because the completion path probes and CLOSES what this
2722
- * returns. `*` marks the unscoped walk, a genuinely different resolution that
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
- // Cached on the same terms as the gh branch below same key, same TTL,
2749
- // same draft exclusion — because the reason to keep a draft uncached is a
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 + PROBE_PR_GH_BACKOFF_MS });
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
- const indexedPaths = allRootsCached
8227
- ? undefined
8228
- : await this.#githubIssuePathsFromIndex(owner, repo);
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 (allRootsCached) {
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 NOT go through `#resolveIssuePr` it must not fall back to
8785
- // gh — so it never saw that method's cache, and `#terminalDependencyIdentities`
8786
- // only ever memoises the TRUE answer. A dependency that is not merged was
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
- await this.#probeCloser({
17976
+ const closeInput = {
18051
17977
  repo: probe.repo,
18052
17978
  prNumber: probe.prNumber,
18053
17979
  expectedIssueKey: issue.key,
18054
17980
  requireTitleMarker: false,
18055
- ...(this.#mount.githubWrite ? { githubWrite: this.#mount.githubWrite } : {}),
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
- if (config.github.identity !== 'app') {
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 (!mount.githubWrite) {
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
- return new AppGithubWriteback(mount.githubWrite, mount.githubRead);
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) &&