diff --git a/.agentworkforce/features/manifest.yaml b/.agentworkforce/features/manifest.yaml index 64a5da1c..b834e1d4 100644 --- a/.agentworkforce/features/manifest.yaml +++ b/.agentworkforce/features/manifest.yaml @@ -861,7 +861,7 @@ categories: - id: pr-completion-detection name: Completion PR Detection api: Factory.start() / Factory.runLoop() - description: Resolve an issue's PR from mounted GitHub records with guarded gh fallback and bounded polling sweeps + description: Resolve an issue's PR from mounted GitHub records with bounded polling sweeps and no local GitHub CLI dependency location: src/orchestrator/factory.ts verify_tier: 5 diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 214bd939..d7f6b8e6 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -7101,7 +7101,7 @@ describe('FactoryLoop', () => { } }) - it('preserves an in-progress GitHub issue when the open-PR safety probe fails', async () => { + it('recovers an in-progress GitHub issue when the mount proves no open PR without invoking gh', async () => { const root = await mkdtemp(join(tmpdir(), 'factory-orphan-pr-probe-failure-')) try { const path = githubIssuePath('AgentWorkforce', 'pear', 56) @@ -7110,6 +7110,7 @@ describe('FactoryLoop', () => { }) const fleet = new FakeFleetClient() const githubWriteback = new RecordingGithubWriteback() + let ghCalls = 0 const factory = createFactory(config({ issueSource: 'github', loop: { registryPath: join(root, 'registry.json') }, @@ -7118,14 +7119,22 @@ describe('FactoryLoop', () => { fleet, triage: new StaticTriage(), githubWriteback, - probePrGhRunner: async () => { throw new Error('GitHub PR lookup unavailable') }, + probePrGhRunner: async () => { + ghCalls += 1 + throw new Error('local gh must not be consulted') + }, }) const report = await factory.runOnce() - expect(report.dispatched).toEqual([]) - expect(githubWriteback.statuses).toEqual([]) - expect(factory.status().counters.githubOrphanRecoveryPrProbeFailures).toBe(1) + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['56']) + expect(githubWriteback.statuses).toEqual([ + { key: '56', status: 'ready' }, + { key: '56', status: 'in-progress' }, + ]) + expect(ghCalls).toBe(0) + expect(factory.status().counters.githubOrphanedInProgressRecovered).toBe(1) + expect(factory.status().counters.githubOrphanRecoveryPrProbeFailures).toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) } @@ -7149,6 +7158,7 @@ describe('FactoryLoop', () => { }) const fleet = new FakeFleetClient() const githubWriteback = new RecordingGithubWriteback() + let ghCalls = 0 const factory = createFactory(config({ issueSource: 'github', loop: { registryPath: join(root, 'registry.json') }, @@ -7157,7 +7167,10 @@ describe('FactoryLoop', () => { fleet, triage: new StaticTriage(), githubWriteback, - probePrGhRunner: async () => { throw new Error('gh fallback must not weaken a partial mount failure') }, + probePrGhRunner: async () => { + ghCalls += 1 + throw new Error('gh must not be invoked after a partial mount failure') + }, }) const report = await factory.runOnce() @@ -7165,6 +7178,7 @@ describe('FactoryLoop', () => { expect(report.dispatched).toEqual([]) expect(fleet.spawns).toEqual([]) expect(githubWriteback.statuses).toEqual([]) + expect(ghCalls).toBe(0) expect(factory.status().counters.githubOrphanRecoveryPrProbeFailures).toBe(1) expect(report.skipped).toContainEqual(expect.objectContaining({ issue: expect.objectContaining({ key: '56' }), @@ -7175,7 +7189,7 @@ describe('FactoryLoop', () => { } }) - it('preserves an in-progress GitHub issue when open-PR discovery is truncated', async () => { + it('ignores a truncated gh candidate list when the mount proves no open PR', async () => { const root = await mkdtemp(join(tmpdir(), 'factory-orphan-pr-truncated-')) try { const path = githubIssuePath('AgentWorkforce', 'pear', 60) @@ -7184,6 +7198,7 @@ describe('FactoryLoop', () => { }) const fleet = new FakeFleetClient() const githubWriteback = new RecordingGithubWriteback() + let ghCalls = 0 const factory = createFactory(config({ issueSource: 'github', loop: { registryPath: join(root, 'registry.json') }, @@ -7192,19 +7207,27 @@ describe('FactoryLoop', () => { fleet, triage: new StaticTriage(), githubWriteback, - probePrGhRunner: async () => ({ - stdout: JSON.stringify(Array.from({ length: 200 }, (_, index) => ghPr(index + 1, { - title: `Unrelated PR ${index + 1}`, - headRefName: `unrelated-${index + 1}`, - }))), - }), + probePrGhRunner: async () => { + ghCalls += 1 + return { + stdout: JSON.stringify(Array.from({ length: 200 }, (_, index) => ghPr(index + 1, { + title: `Unrelated PR ${index + 1}`, + headRefName: `unrelated-${index + 1}`, + }))), + } + }, }) const report = await factory.runOnce() - expect(report.dispatched).toEqual([]) - expect(githubWriteback.statuses).toEqual([]) - expect(factory.status().counters.githubOrphanRecoveryPrProbeFailures).toBe(1) + expect(report.dispatched.map((result) => result.issue.key)).toEqual(['60']) + expect(githubWriteback.statuses).toEqual([ + { key: '60', status: 'ready' }, + { key: '60', status: 'in-progress' }, + ]) + expect(ghCalls).toBe(0) + expect(factory.status().counters.githubOrphanedInProgressRecovered).toBe(1) + expect(factory.status().counters.githubOrphanRecoveryPrProbeFailures).toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) } @@ -21588,16 +21611,14 @@ describe('FactoryLoop', () => { expect(factory.status().counters.completionSweepDraftPr).toBe(1) }) - it('PR-state sweep resolves fresh PRs through gh when the mount is missing them', async () => { + it('PR-state sweep does not resolve fresh PRs through local gh when the mount is missing them', async () => { const mount = new FakeMountClient({ [issuePath(355)]: issueFile(355), - [issuePath(356)]: issueFile(356), - [issuePath(357)]: issueFile(357), }) const fleet = new FakeFleetClient() const closeInputs: Array> = [] const ghCalls: string[][] = [] - const factory = createFactory(config({ batchSize: 2 }), { + const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage(), @@ -21641,52 +21662,54 @@ describe('FactoryLoop', () => { await factory.runOnce() await factory.runLoop({ maxIterations: 1 }) - expect(ghCalls).toHaveLength(2) - expect(ghCalls.every((args) => args.includes('--repo') && args.includes('AgentWorkforce/pear'))).toBe(true) - expect(closeInputs).toEqual([ - { repo: 'AgentWorkforce/pear', prNumber: 856, expectedIssueKey: 'AR-355', requireTitleMarker: false }, - { repo: 'AgentWorkforce/pear', prNumber: 857, expectedIssueKey: 'AR-356', requireTitleMarker: false }, - ]) - expect(fleet.spawns.map((spawn) => spawn.name)).toContain('ar-357-impl-pear') - expect(factory.status().counters.probePrGhResolveHits).toBe(2) + expect(ghCalls).toEqual([]) + expect(closeInputs).toEqual([]) + expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['AR-355']) + expect(factory.status().counters.completionSweepMissingPr).toBe(1) + expect(factory.status().counters.probePrGhResolveAttempts).toBeUndefined() + expect(factory.status().counters.probePrGhResolveHits).toBeUndefined() }) - it('gh PR fallback rejects fuzzy over-matches and numeric-prefix collisions', async () => { + it('mount-only PR resolution never consults gh fuzzy over-matches or numeric-prefix collisions', async () => { const mount = new FakeMountClient({ [issuePath(229)]: issueFile(229) }) const fleet = new FakeFleetClient() const closeInputs: unknown[] = [] + const ghCalls: string[][] = [] const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage(), - probePrGhRunner: async () => ({ - stdout: JSON.stringify([ - ghPr(287, { - title: 'Add PR-state completion sweep', - body: 'This fix PR mentions AR-229 in tests but is not its issue PR.', - headRefName: 'factory-sdk-pr-state-completion-sb-impl3', - state: 'OPEN', - }), - ghPr(291, { - title: 'AR-22: wrong issue', - body: 'Linear: AR-22', - headRefName: 'ar-22-9-not-229', - state: 'OPEN', - }), - ghPr(292, { - title: 'AR-229-1: wrong child issue', - body: '', - headRefName: 'ar-229-1-is-positive', - state: 'OPEN', - }), - ghPr(293, { - title: 'AR-2290: wrong prefix', - body: '', - headRefName: 'ar-2290-is-positive', - state: 'OPEN', - }), - ]), - }), + probePrGhRunner: async (args) => { + ghCalls.push(args) + return { + stdout: JSON.stringify([ + ghPr(287, { + title: 'Add PR-state completion sweep', + body: 'This fix PR mentions AR-229 in tests but is not its issue PR.', + headRefName: 'factory-sdk-pr-state-completion-sb-impl3', + state: 'OPEN', + }), + ghPr(291, { + title: 'AR-22: wrong issue', + body: 'Linear: AR-22', + headRefName: 'ar-22-9-not-229', + state: 'OPEN', + }), + ghPr(292, { + title: 'AR-229-1: wrong child issue', + body: '', + headRefName: 'ar-229-1-is-positive', + state: 'OPEN', + }), + ghPr(293, { + title: 'AR-2290: wrong prefix', + body: '', + headRefName: 'ar-2290-is-positive', + state: 'OPEN', + }), + ]), + } + }, probeCloser: async (input) => { closeInputs.push(input) return { repo: input.repo, prNumber: input.prNumber, state: 'CLOSED' } @@ -21697,19 +21720,22 @@ describe('FactoryLoop', () => { await factory.runLoop({ maxIterations: 1 }) expect(closeInputs).toEqual([]) + expect(ghCalls).toEqual([]) expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['AR-229']) expect(factory.status().counters.completionSweepMissingPr).toBe(1) }) - it('gh PR fallback fails closed when gh is unavailable', async () => { + it('mount-only PR resolution does not invoke an unavailable gh binary', async () => { const mount = new FakeMountClient({ [issuePath(358)]: issueFile(358) }) const fleet = new FakeFleetClient() const closeInputs: unknown[] = [] + let ghCalls = 0 const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage(), probePrGhRunner: async () => { + ghCalls += 1 throw new Error('gh auth missing') }, probeCloser: async (input) => { @@ -21722,12 +21748,13 @@ describe('FactoryLoop', () => { await factory.runLoop({ maxIterations: 1 }) expect(closeInputs).toEqual([]) + expect(ghCalls).toBe(0) expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['AR-358']) expect(factory.status().counters.completionSweepMissingPr).toBe(1) expect(factory.status().counters.done).toBeUndefined() }) - it('gh PR fallback backs off repeated not-found lookups', async () => { + it('mount-only PR resolution never starts or retries a local gh lookup after repeated misses', async () => { const clock = new ManualClock() const mount = new FakeMountClient({ [issuePath(361)]: issueFile(361) }) const fleet = new FakeFleetClient() @@ -21756,19 +21783,19 @@ describe('FactoryLoop', () => { await factory.runLoop({ maxIterations: 1 }) await factory.runLoop({ maxIterations: 1 }) - expect(ghCalls).toHaveLength(1) - expect(factory.status().counters.probePrGhBackoffSkips).toBe(1) + expect(ghCalls).toEqual([]) + expect(factory.status().counters.probePrGhBackoffSkips).toBeUndefined() expect(factory.status().counters.completionSweepMissingPr).toBe(2) clock.advance(60_000) await factory.runLoop({ maxIterations: 1 }) - expect(ghCalls).toHaveLength(2) + expect(ghCalls).toEqual([]) expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['AR-361']) expect(factory.status().counters.done).toBeUndefined() }) - it('gh PR fallback skips draft PRs and backs off repeated unresolved lookups', async () => { + it('mount-only PR resolution ignores a gh-only draft across repeated sweeps', async () => { const clock = new ManualClock() const mount = new FakeMountClient({ [issuePath(359)]: issueFile(359) }) const fleet = new FakeFleetClient() @@ -21797,34 +21824,33 @@ describe('FactoryLoop', () => { await factory.dispatch(await factory.triageIssue(parseLinearIssue(issuePath(359), issueFile(359)))) await factory.runLoop({ maxIterations: 1 }) await factory.runLoop({ maxIterations: 1 }) - expect(ghCalls).toHaveLength(1) - expect(factory.status().counters.probePrGhBackoffSkips).toBe(1) - expect(factory.status().counters.completionSweepDraftPr).toBe(1) + expect(ghCalls).toEqual([]) + expect(factory.status().counters.probePrGhBackoffSkips).toBeUndefined() + expect(factory.status().counters.completionSweepDraftPr).toBeUndefined() + expect(factory.status().counters.completionSweepMissingPr).toBe(2) clock.advance(60_000) await factory.runLoop({ maxIterations: 1 }) - expect(ghCalls).toHaveLength(2) + expect(ghCalls).toEqual([]) expect(factory.status().inFlight.map((issue) => issue.key)).toEqual(['AR-359']) }) - it('treats already-closed gh-resolved probe PRs as completed instead of re-wedging', async () => { - const mount = new FakeMountClient({ [issuePath(360)]: issueFile(360) }) + it('treats already-closed mount-resolved probe PRs as completed instead of re-wedging', async () => { + const mount = new FakeMountClient({ + [issuePath(360)]: issueFile(360), + '/github/repos/AgentWorkforce__pear/pulls/by-id/860.json': prFile(860, { + title: 'Add already closed probe work', + body: '', + head_ref: 'ar-360-closed-work', + state: 'CLOSED', + }), + }) const fleet = new FakeFleetClient() const closeViewCalls: string[][] = [] const factory = createFactory(config(), { mount, fleet, triage: new StaticTriage(), - probePrGhRunner: async () => ({ - stdout: JSON.stringify([ - ghPr(860, { - title: 'Add already closed probe work', - body: '', - headRefName: 'ar-360-closed-work', - state: 'CLOSED', - }), - ]), - }), probeCloser: (input) => closeProbePr({ ...input, githubWrite: { diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 3ecd7c33..5d7af692 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -365,8 +365,7 @@ const LIVE_EVENT_DRAIN_BATCH_SIZE = 5 const COMPLETION_SWEEP_INTERVAL_MS = 15_000 const COMPLETION_SWEEP_BATCH_SIZE = 2 const PREVIEW_SWEEP_INTERVAL_MS = 60_000 -const PROBE_PR_GH_BACKOFF_MS = 60_000 -const PROBE_PR_GH_CANDIDATE_LIMIT = 200 +const PROBE_PR_RESOLVED_CACHE_MS = 60_000 const PUBLISHED_PR_CONFIRM_ATTEMPTS = 20 const PUBLISHED_PR_CONFIRM_DELAY_MS = 100 const SLACK_REPLY_EVENTS_LIMIT = 100 @@ -420,8 +419,8 @@ const BABYSITTER_PR_SNAPSHOT_DEAD_LETTER_LIMIT = 256 const BABYSITTER_PR_SNAPSHOT_DRAIN_PER_SWEEP = 16 // A durably faulted mount would otherwise log an error per path per sweep. const BABYSITTER_PR_SNAPSHOT_ESCALATED_LOG_EVERY = 20 -// Adoption probes an issue's open PR through the (already gh-backed-off) probe -// resolver, so it runs on its own slower cadence than the completion sweep. +// Adoption probes an issue's open PR through the mounted projection, so its +// full-tree fallback runs on a slower cadence than the completion sweep. const BABYSITTER_ORPHAN_SWEEP_INTERVAL_MS = 60_000 // Warn once per PR identity that arrives unowned, then fall back to debug. The // counter carries the true volume; the log only has to make it discoverable. @@ -1148,7 +1147,6 @@ export class FactoryLoop implements Factory { readonly #publishedPullRequests = new Map() readonly #previewReferences = new Map() readonly #removedPreviewIds = new Set() - readonly #probePrGhBackoffUntilMs = new Map() readonly #probePrResolvedCache = new Map() // GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion // cycles read the mirror directly instead of re-scanning all Linear issues. @@ -2811,14 +2809,6 @@ export class FactoryLoop implements Factory { } if (pr.draft) { this.#increment('completionSweepDraftPr') - // Must match the key `#completionPrForIssue` resolves under — - // same options, same repo scope — or this backoff is written - // under a name nothing reads and the draft PR is re-fetched from - // gh on every pass. - this.#probePrGhBackoffUntilMs.set( - this.#probePrCacheKey(issue, { repo: this.#probeRepoForIssue(issue) }), - this.#clock.now() + PROBE_PR_GH_BACKOFF_MS, - ) return undefined } if (record.decision.implementers.length > 1 && !await this.#allImplementersHaveCompletionPr(record)) { @@ -2948,9 +2938,7 @@ export class FactoryLoop implements Factory { } // Candidate PR meta paths for an ownerless record, newest PR first. Resolution - // reuses the durable dispatch receipts and then the probe resolver (which - // carries its own gh backoff, so a record whose implementer has not opened a - // PR yet costs no more than a cached miss). + // reuses the durable dispatch receipts and then the mounted probe resolver. async #orphanedPrMetaPaths(record: InFlightIssue): Promise { const wanted: Array<{ repo: string; prNumber: number }> = [] if (this.#usesDurableDispatchLifecycle()) { @@ -3102,19 +3090,13 @@ export class FactoryLoop implements Factory { } /** - * The key for BOTH `#probePrResolvedCache` and `#probePrGhBackoffUntilMs`. - * - * Shared rather than inlined because those two maps are written from more than - * one place: `#resolveIssuePr` writes both, and the completion sweep writes a - * draft-PR backoff directly. Those writes must agree on the key or the backoff - * is set under a name nothing reads, which silently costs a gh call per pass. + * The key for `#probePrResolvedCache`. * * `repo` is part of the key because it narrows which pull requests the * resolution can even see — an issue whose route changes must not be served - * the previous repository's PR, or held off gh by the previous repository's - * negative backoff, because the completion path probes and CLOSES what this - * returns. `*` marks the unscoped walk, a genuinely different resolution that - * must not share an entry with any single-repo one. + * the previous repository's PR, because the completion path probes and + * CLOSES what this returns. `*` marks the unscoped walk, a genuinely + * different resolution that must not share an entry with any single-repo one. * * Every dimension is a trailing `:`-prefixed segment so the completion * invalidation — which clears `stateKey` plus everything starting @@ -3161,34 +3143,13 @@ export class FactoryLoop implements Factory { // method. The cache had a reader and no writer on the hot path, so the // full tree walk repeated for every caller, on every sweep, forever. // - // Cached on the same terms as the gh branch below — same key, same TTL, - // same draft exclusion — because the reason to keep a draft uncached is a - // property of the PR (its state is about to flip and the caller wants to - // see that promptly), not of which resolver observed it. + // Keep drafts uncached because their state is expected to flip and callers + // need to observe that promptly. if (!mountPr.draft) { - this.#probePrResolvedCache.set(key, { pr: mountPr, expiresAtMs: now + PROBE_PR_GH_BACKOFF_MS }) + this.#probePrResolvedCache.set(key, { pr: mountPr, expiresAtMs: now + PROBE_PR_RESOLVED_CACHE_MS }) } return mountPr } - - const backoffUntil = this.#probePrGhBackoffUntilMs.get(key) ?? 0 - if (backoffUntil > now) { - this.#increment('probePrGhBackoffSkips') - return undefined - } - - const ghPr = await resolveIssuePrFromGh(this.#probePrGhRunner, this.#config, issue, opts, this.#logger) - this.#increment('probePrGhResolveAttempts') - if (ghPr) { - this.#probePrGhBackoffUntilMs.delete(key) - if (!ghPr.draft) { - this.#probePrResolvedCache.set(key, { pr: ghPr, expiresAtMs: now + PROBE_PR_GH_BACKOFF_MS }) - } - this.#increment('probePrGhResolveHits') - return ghPr - } - - this.#probePrGhBackoffUntilMs.set(key, now + PROBE_PR_GH_BACKOFF_MS) return undefined } @@ -9656,9 +9617,9 @@ export class FactoryLoop implements Factory { if (!issue) return false const repo = dependencyRepoForIssue(issue, undefined, this.#config) if (!repo) return false - // This probe does NOT go through `#resolveIssuePr` — it must not fall back to - // gh — so it never saw that method's cache, and `#terminalDependencyIdentities` - // only ever memoises the TRUE answer. A dependency that is not merged was + // This specialized probe does not go through `#resolveIssuePr`, so it never + // saw that method's cache, and `#terminalDependencyIdentities` only ever + // memoises the TRUE answer. A dependency that is not merged was // therefore re-walked in full for every issue declaring it, on every sweep; // several issues blocked on one dependency multiplied a single tree walk by // the number of blocked issues. Memoise the negative answer too, on exactly @@ -16703,11 +16664,6 @@ export class FactoryLoop implements Factory { this.#probePrResolvedCache.delete(cacheKey) } } - for (const backoffKey of [...this.#probePrGhBackoffUntilMs.keys()]) { - if (backoffKey === stateKey || backoffKey.startsWith(`${stateKey}:`)) { - this.#probePrGhBackoffUntilMs.delete(backoffKey) - } - } // Cancellation must see the subscription identity so it can issue the // idempotent Relayfile DELETE before clearing the local owner maps. await this.#cancelBabysittersForIssue(record.issue) @@ -21709,96 +21665,6 @@ export const resolveIssuePrFromMount = async ( return resolved } -const resolveIssuePrFromGh = async ( - run: GhRunner, - config: FactoryConfig, - issue: LinearIssue, - opts: { - requireTitleMarker?: boolean - titleMarker?: string - openOnly?: boolean - failOnLookupError?: boolean - allowLegacyGithubBranch?: boolean - } = {}, - logger?: Logger, -): Promise => { - const candidates: Array = [] - let lookupFailures = 0 - for (const repo of reposFromConfig(config)) { - let payload: unknown - try { - const result = await run([ - 'pr', - 'list', - '--repo', - repo, - '--state', - 'all', - '--json', - 'number,title,body,headRefName,headRepository,headRepositoryOwner,isCrossRepository,isDraft,state,url', - '--limit', - String(PROBE_PR_GH_CANDIDATE_LIMIT), - ]) - if (!result.stdout.trim()) { - lookupFailures += 1 - logger?.warn?.('[factory] gh PR resolver returned empty output', { issue: issue.key, repo }) - continue - } - payload = parseJsonContent(result.stdout) - } catch (error) { - lookupFailures += 1 - logger?.warn?.('[factory] gh PR resolver failed', { issue: issue.key, repo, error }) - continue - } - - if (!Array.isArray(payload)) { - lookupFailures += 1 - logger?.warn?.('[factory] gh PR resolver returned non-array payload', { issue: issue.key, repo }) - continue - } - if (payload.length >= PROBE_PR_GH_CANDIDATE_LIMIT) { - logger?.warn?.('[factory] gh PR resolver hit candidate limit', { issue: issue.key, repo, limit: PROBE_PR_GH_CANDIDATE_LIMIT }) - if (opts.failOnLookupError) lookupFailures += 1 - } - - for (const entry of payload) { - const pr = ghProbePrCandidate(entry) - if ( - !pr || - (!factoryBranchMatchesIssue(pr.headRef, issue.key) && - !(opts.allowLegacyGithubBranch && legacyGithubBranchMatchesIssue(pr.headRef, issue))) - ) continue - if (opts.openOnly && normalizePrState(pr.state) !== 'OPEN') continue - const score = issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts) - if (score <= 0) continue - candidates.push({ - repo, - prNumber: pr.number, - draft: pr.draft, - headRef: pr.headRef, - headRepo: pr.headRepo, - crossRepository: pr.crossRepository ?? ( - pr.headRepo ? pr.headRepo.toLowerCase() !== repo.toLowerCase() : undefined - ), - state: pr.state, - url: pr.url, - score, - open: normalizePrState(pr.state) === 'OPEN', - }) - } - } - - const resolved = candidates.sort((a, b) => - b.score - a.score || - Number(b.open) - Number(a.open) || - b.prNumber - a.prNumber - )[0] - if (!resolved && opts.failOnLookupError && lookupFailures > 0) { - throw new Error(`Unable to confirm open pull request state for ${issue.key} in ${lookupFailures} configured repository lookup(s)`) - } - return resolved -} - const reposFromConfig = (config: FactoryConfig): string[] => { const repos = new Set([ ...Object.values(config.repos.byLabel), @@ -21936,43 +21802,6 @@ const readProbePrCandidate = async ( } } -const ghProbePrCandidate = ( - value: unknown, -): { - number: number - title: string - body: string - headRef: string - headRepo?: string - crossRepository?: boolean - draft?: boolean - state?: string - url?: string -} | undefined => { - const payload = asRecord(value) - if (!payload) return undefined - const number = numberValue(payload.number) - if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0) return undefined - const headRepository = asRecord(payload.headRepository) - const headRepositoryOwner = asRecord(payload.headRepositoryOwner) - const headRepo = githubRepositoryFullName(payload.headRepository) ?? (() => { - const name = stringValue(headRepository?.name) - const owner = stringValue(headRepositoryOwner?.login) ?? stringValue(headRepositoryOwner?.name) - return name && owner ? `${owner}/${name}` : undefined - })() - return { - number, - title: stringValue(payload.title) ?? '', - body: stringValue(payload.body) ?? '', - headRef: stringValue(payload.headRefName) ?? '', - headRepo, - crossRepository: booleanValue(payload.isCrossRepository), - draft: booleanValue(payload.isDraft), - state: stringValue(payload.state), - url: stringValue(payload.url), - } -} - const issuePrMatchScore = ( pr: { title: string; body: string; headRef: string }, issue: LinearIssue,