diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 00088acb6861..036c7ed4b74e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -22,7 +22,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; @@ -38,6 +37,13 @@ import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequest /** Checks take minutes; faster sweeps waste the account's shared host quota. */ const SWEEP_MINUTES = 2; +/** + * While checks run, a sweep of conditional fingerprints spends no quota when nothing moved, so + * it can follow CI closely. GitHub's secondary limits count requests, hence not faster. + */ +const ACTIVE_SWEEP_SECONDS = 30; +/** A push starts checks that may not be listed yet, so keep the quick pace for a while. */ +const RECENT_PUSH_MS = 5 * 60_000; const READ_FAILURE_LIMIT = 8; /** Without fingerprints, reread quiet PRs on snapshot changes or this interval to catch edits. */ const QUIET_REREAD_MS = 10 * 60_000; @@ -78,6 +84,7 @@ interface LastRead { readonly at: number; readonly fingerprint: string; readonly inFlight: boolean; + readonly conditional: boolean; readonly remarksComplete: boolean; readonly watches: ReadonlySet; readonly status: string | null; @@ -151,7 +158,10 @@ function planRead( last.remarks !== fingerprint.remarks || !last.remarksComplete || now - last.at >= FINGERPRINT_REREAD_MS; - return { detail: activity || last.status !== fingerprint.status || last.inFlight, activity }; + // A conditional fingerprint already carries check and mergeability state, so in-flight work + // shows up as a status change there. + const settling = last.inFlight && fingerprint.conditional !== true; + return { detail: activity || last.status !== fingerprint.status || settling, activity }; } function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { @@ -176,6 +186,8 @@ export class PullRequestWatchReactor extends Context.Service< { readonly start: () => Effect.Effect; readonly sweep: Effect.Effect; + /** How long to wait after the latest sweep before the next one. */ + readonly sweepDelayMs: Effect.Effect; } >()("t3/orchestration-v2/PullRequestWatchReactor") {} @@ -522,6 +534,7 @@ export const make = Effect.gen(function* () { ? (threadTails.get(group.key)?.fetchedAt ?? now) : last.at, fingerprint: group.fingerprint, + conditional: fingerprint?.conditional === true, inFlight: detail.mergeability === "unknown" || detail.checks.some((check) => check.status === "pending"), @@ -555,6 +568,8 @@ export const make = Effect.gen(function* () { }); }); + let nextSweepMs = SWEEP_MINUTES * 60_000; + const sweep = Effect.gen(function* () { const threads = yield* projections.getThreadsWithPullRequests(); const targets = threads.flatMap((thread) => @@ -606,6 +621,7 @@ export const make = Effect.gen(function* () { const fingerprints = yield* Effect.forEach(groups, fingerprintOf, { concurrency: "unbounded", }); + const paused = fingerprints.includes("paused"); yield* Effect.forEach( groups, (group, index) => { @@ -620,17 +636,35 @@ export const make = Effect.gen(function* () { }, { concurrency: 4, discard: true }, ); + const now = yield* Clock.currentTimeMillis; + const quick = + !paused && + groups.some((group) => { + const last = lastReads.get(group.key); + return ( + last?.conditional === true && + (last.inFlight || + group.targets.some((target) => now - lifeOf(target).pushedAt < RECENT_PUSH_MS)) + ); + }); + nextSweepMs = quick ? ACTIVE_SWEEP_SECONDS * 1000 : SWEEP_MINUTES * 60_000; }).pipe( Effect.catchCause(logFailure("pull request watch sweep failed", {})), Effect.withSpan("PullRequestWatchReactor.sweep"), ); + const sweepDelayMs = Effect.sync(() => nextSweepMs); + const start: PullRequestWatchReactor["Service"]["start"] = () => forkParked( - sweep.pipe(Effect.repeat(Schedule.spaced(`${SWEEP_MINUTES} minutes`)), Effect.asVoid), + sweep.pipe( + Effect.andThen(Effect.flatMap(sweepDelayMs, Effect.sleep)), + Effect.forever, + Effect.asVoid, + ), ); - return { start, sweep } satisfies PullRequestWatchReactor["Service"]; + return { start, sweep, sweepDelayMs } satisfies PullRequestWatchReactor["Service"]; }); export const layer = Layer.effect(PullRequestWatchReactor, make); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 34fdb573c51e..4f8c63f90ce0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,4 @@ +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -3452,6 +3453,134 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("sweeps quickly only while checks run and the fingerprint is conditional", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-cadence"); + const projectId = ProjectId.make("pr-watch-cadence-project"); + yield* seedProject({ + projectId, + title: "Watch cadence", + workspaceRoot: "/workspace/watch-cadence", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-cadence-create"), + threadId, + projectId, + title: "Watch cadence", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-cadence-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 15, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + }); + + let checkStatus: "pending" | "success" = "pending"; + let fingerprint: "conditional" | "graphql" | "paused" = "conditional"; + let detailReads = 0; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => + fingerprint === "paused" + ? Effect.fail( + new PullRequestOperationError({ + operation: "watchFingerprint", + detail: "Rate limited", + cause: new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "getChangeRequestWatchFingerprint", + reason: "rate-limited", + detail: "Rate limited", + }), + }), + ) + : Effect.succeed({ + status: checkStatus, + remarks: "0", + ...(fingerprint === "conditional" ? { conditional: true as const } : {}), + }), + invalidate: () => Effect.void, + detail: () => + Effect.sync(() => { + detailReads += 1; + return { + ...watchedPullRequestDetail({ + projectId, + number: 15, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "6074dd48ff", + checks: [ + { + name: "lint", + status: checkStatus, + description: null, + url: null, + required: true, + }, + ], + }; + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), + ), + ); + const delayAfterSweep = Effect.andThen(reactor.sweep, reactor.sweepDelayMs); + + assert.equal(yield* delayAfterSweep, 30_000, "checks are running"); + assert.equal(yield* delayAfterSweep, 30_000, "checks are still running"); + assert.equal(detailReads, 1, "an unchanged conditional fingerprint needs no reread"); + fingerprint = "graphql"; + assert.equal( + yield* delayAfterSweep, + 120_000, + "reading costs quota without a conditional fingerprint", + ); + fingerprint = "paused"; + assert.equal(yield* delayAfterSweep, 120_000, "the host is rate limited"); + fingerprint = "conditional"; + checkStatus = "success"; + yield* TestClock.adjust("10 minutes"); + assert.equal(yield* delayAfterSweep, 120_000, "nothing is in flight"); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-cadence-end"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 15, + watching: false, + }); + }), + ); + it.effect("wakes for checks that complete after the no-checks notice on the same head", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 8e31e55ba2f9..4923af424e3c 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -69,6 +69,8 @@ export class PullRequestProviderError extends Schema.TaggedError { }), ); + { + const pull = (fields: Record = {}) => + encodeJson({ + state: "open", + merged_at: null, + mergeable: true, + mergeable_state: "blocked", + updated_at: "2026-10-01T00:00:00Z", + comments: 1, + review_comments: 2, + head: { sha: "abc123" }, + ...fields, + }); + const runs = encodeJson({ + check_runs: [ + { status: "completed", conclusion: "success" }, + { status: "in_progress", conclusion: null }, + ], + }); + const statuses = encodeJson({ statuses: [{ state: "success" }] }); + const read = Effect.flatMap(GitHubPullRequestApi.GitHubPullRequestApi, (cli) => + cli.getPullRequestWatchFingerprint({ ...pullRequestInput, repository: "acme/watch" }), + ); + const answerFrom = ( + bodies: Readonly>, + revalidated: boolean, + ) => + mockedExecute.mockImplementation((call) => { + assert.strictEqual(call.kind, "rest"); + const kind = (["check-runs", "status"] as const).find((name) => + call.kind === "rest" ? call.path.includes(`/${name}?`) : false, + ); + return Effect.succeed( + revalidated && call.kind === "rest" && call.ifNoneMatch !== undefined + ? output("", false, false, {}, 304) + : output(bodies[kind ?? "pulls"], false, false, { etag: `W/"${kind ?? "pulls"}"` }), + ); + }); + + it.effect("costs no quota while nothing moved, and reads again when it did", () => + Effect.gen(function* () { + answerFrom({ pulls: pull(), "check-runs": runs, status: statuses }, true); + const first = yield* read; + const second = yield* read; + assert.deepStrictEqual(second, first); + assert.deepStrictEqual(first, { + status: "OPEN MERGEABLE blocked abc123 in_progress:1,success:1 success:1", + remarks: "1 2 2026-10-01T00:00:00Z", + conditional: true, + }); + expect(mockedExecute).toHaveBeenCalledTimes(6); + for (const index of [0, 1, 2]) expect(callAt(index)).not.toHaveProperty("ifNoneMatch"); + for (const index of [3, 4, 5]) { + expect(callAt(index)).toMatchObject({ ifNoneMatch: expect.stringContaining("W/") }); + } + + answerFrom( + { + pulls: pull({ comments: 2, updated_at: "2026-10-01T00:05:00Z" }), + "check-runs": runs, + status: statuses, + }, + false, + ); + const edited = yield* read; + assert.strictEqual(edited?.remarks, "2 2 2026-10-01T00:05:00Z"); + assert.strictEqual(edited?.status, first?.status); + }), + ); + + it.effect("moves the status when checks change under an unchanged pull request", () => + Effect.gen(function* () { + answerFrom({ pulls: pull(), "check-runs": runs, status: statuses }, false); + const before = yield* read; + answerFrom( + { + pulls: pull(), + "check-runs": encodeJson({ + check_runs: [{ status: "completed", conclusion: "failure" }], + }), + status: statuses, + }, + false, + ); + const after = yield* read; + assert.notStrictEqual(after?.status, before?.status); + assert.strictEqual(after?.remarks, before?.remarks); + }), + ); + + it.effect("falls back to GraphQL when REST cannot answer", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + call.kind === "rest" + ? Effect.fail( + new GitHubRequestPolicy.GitHubApiNotFoundError({ + host: "github.com", + operation: "getPullRequestWatchFingerprint", + }), + ) + : Effect.succeed( + output( + encodeJson({ + data: { + _0_repository: { + pullRequest: { + state: "OPEN", + mergeable: "MERGEABLE", + headRefOid: "abc123", + comments: { totalCount: 0, nodes: [] }, + reviews: { totalCount: 0, nodes: [] }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [] }, + }, + }, + }, + }), + ), + ), + ); + const fingerprint = yield* read.pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const result = yield* Fiber.join(fingerprint); + assert.isDefined(result); + assert.notProperty(result, "conditional"); + expect(restCallsTo("pulls/7")).toHaveLength(1); + + // The host is remembered as unsupported, so the next read goes straight to GraphQL. + const again = yield* read.pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + yield* Fiber.join(again); + expect(restCallsTo("pulls/7")).toHaveLength(1); + }), + ); + + it.effect("falls back to GraphQL when the checks span more than one page", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + call.kind === "rest" + ? Effect.succeed( + call.path.includes("/check-runs?") + ? output(runs, false, false, { + etag: 'W/"runs"', + link: '; rel="next"', + }) + : call.path.includes("/status?") + ? output(statuses, false, false, { etag: 'W/"status"' }) + : output(pull(), false, false, { etag: 'W/"pull"' }), + ) + : Effect.succeed(output('{"data":{"_0_repository":{"pullRequest":null}}}')), + ); + const fingerprint = yield* read.pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + assert.isNull(yield* Fiber.join(fingerprint)); + }), + ); + } + it.effect("reads a host that refuses the stacks preview as not stacked", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 9597e545f61f..830f427ad601 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1,6 +1,7 @@ import { isCommitSha, normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import { makeConditionalWatchFingerprint } from "./gitHubConditionalFingerprint.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -922,6 +923,7 @@ export const make = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const fileSystem = yield* FileSystem.FileSystem; const revalidateChecks = yield* makeChecksRevalidator; + const conditionalWatchFingerprint = yield* makeConditionalWatchFingerprint; const client = yield* GraphQLClient.make(PullRequestGraphQLGroup).pipe( Effect.provide(GitHubGraphQlClient.layer), ); @@ -1693,19 +1695,26 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); + const graphqlWatchFingerprint = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => { + const lookup = pullRequestLookup(input); + if (lookup === null) return Effect.succeed(null); + return GraphQLResolver.request(lookup, watchFingerprintResolver).pipe( + Effect.map((result) => { + const pullRequest = result.repository?.pullRequest; + return pullRequest == null ? null : toPullRequestWatchFingerprint(pullRequest); + }), + Effect.catchIf(isNotFoundOnly, () => Effect.succeed(null)), + (effect) => batchedRead({ ...input, operation: "getPullRequestWatchFingerprint" }, effect), + ); + }; + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = - (input) => { - const lookup = pullRequestLookup(input); - if (lookup === null) return Effect.succeed(null); - return GraphQLResolver.request(lookup, watchFingerprintResolver).pipe( - Effect.map((result) => { - const pullRequest = result.repository?.pullRequest; - return pullRequest == null ? null : toPullRequestWatchFingerprint(pullRequest); - }), - Effect.catchIf(isNotFoundOnly, () => Effect.succeed(null)), - (effect) => batchedRead({ ...input, operation: "getPullRequestWatchFingerprint" }, effect), - ); - }; + (input) => conditionalWatchFingerprint(input, graphqlWatchFingerprint(input)); const statsResolver = GraphQLResolver.aliased(client.PullRequestStats, { batchKey: GitHubGraphQlClient.batchKey, diff --git a/packages/source-control-github/src/server/gitHubConditionalFingerprint.ts b/packages/source-control-github/src/server/gitHubConditionalFingerprint.ts new file mode 100644 index 000000000000..382e30d73b2b --- /dev/null +++ b/packages/source-control-github/src/server/gitHubConditionalFingerprint.ts @@ -0,0 +1,184 @@ +import * as Cache from "effect/Cache"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; +import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitHubRest from "./GitHubRest.ts"; +import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; +import type { GitHubPullRequestWatchFingerprint } from "./gitHubPullRequestJson.ts"; + +const PullSchema = Schema.Struct({ + state: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + mergeable: Schema.NullOr(Schema.Boolean), + mergeable_state: Schema.optional(Schema.String), + updated_at: Schema.String, + comments: Schema.Int, + review_comments: Schema.Int, + head: Schema.Struct({ sha: Schema.String }), +}); +const decodePull = Schema.decodeUnknownOption(Schema.fromJsonString(PullSchema)); + +const CheckRunsSchema = Schema.Struct({ + check_runs: Schema.Array( + Schema.Struct({ + status: Schema.String, + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +const decodeCheckRuns = Schema.decodeUnknownOption(Schema.fromJsonString(CheckRunsSchema)); + +const StatusSchema = Schema.Struct({ + statuses: Schema.Array(Schema.Struct({ state: Schema.String })), +}); +const decodeStatus = Schema.decodeUnknownOption(Schema.fromJsonString(StatusSchema)); + +const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); + +const countStates = (states: ReadonlyArray) => { + const counts = new Map(); + for (const state of states) counts.set(state, (counts.get(state) ?? 0) + 1); + return [...counts] + .map(([state, count]) => `${state}:${count}`) + .toSorted() + .join(","); +}; + +interface Validated { + readonly etag: string; + readonly summary: unknown; +} + +/** + * The watch fingerprint from conditional REST reads. A 304 spends no quota, and none of these + * reads moves for an idle pull request. `fallback` is the GraphQL read, used whenever REST + * cannot answer: no credential, a host or page size REST does not serve, or an unparseable body. + */ +export const makeConditionalWatchFingerprint = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + const entries = yield* Cache.makeWith( + (_key: string) => + Effect.sync(() => ({ + gate: Semaphore.makeUnsafe(1), + validators: new Map(), + supported: true, + })), + { capacity: 128, timeToLive: () => "30 minutes" }, + ); + + return ( + input: ProviderRepositoryRef & { readonly number: number }, + fallback: Effect.Effect, + ) => + Effect.gen(function* () { + const host = normalizeHost(input.host); + const held = yield* Effect.option(api.credential(host)); + if (Option.isNone(held)) return yield* fallback; + const { token, fingerprint } = held.value; + const entry = yield* Cache.get( + entries, + `${fingerprint}\0${host}\0${input.repository}\0${input.number}`, + ); + return yield* entry.gate.withPermit( + Effect.gen(function* () { + if (!entry.supported) return yield* fallback; + const unsupported = Effect.sync(() => { + entry.supported = false; + }).pipe(Effect.andThen(fallback)); + const read = Effect.fn("gitHubConditionalFingerprint.read")(function* ( + path: string, + summarize: (response: GitHubRest.GitHubRestResponse) => A | null, + ) { + const previous = entry.validators.get(path); + const response = yield* api.rest({ + host, + operation: "getPullRequestWatchFingerprint", + path, + ...(previous === undefined ? {} : { ifNoneMatch: previous.etag }), + }); + if (response.status === 304 && previous !== undefined) return previous.summary as A; + const etag = response.headers["etag"]?.trim(); + const summary = + response.status === 200 && + !response.truncated && + // Another page may hold runs this read cannot see. + !/rel="next"/.test(response.headers["link"] ?? "") + ? summarize(response) + : null; + if (summary === null || !etag) { + entry.validators.delete(path); + return null; + } + entry.validators.set(path, { etag, summary }); + return summary; + }); + const root = `repos/${input.repository}`; + const pull = yield* read(`${root}/pulls/${input.number}`, (response) => { + const decoded = decodePull(response.body); + if (Option.isNone(decoded)) return null; + const pr = decoded.value; + return { + sha: pr.head.sha, + status: [ + pr.merged_at ? "MERGED" : pr.state.toUpperCase(), + pr.mergeable === null ? "UNKNOWN" : pr.mergeable ? "MERGEABLE" : "CONFLICTING", + pr.mergeable_state ?? "", + pr.head.sha, + ].join(" "), + remarks: [pr.comments, pr.review_comments, pr.updated_at].join(" "), + }; + }); + if (pull === null) return yield* unsupported; + const { sha } = pull; + const [runs, statuses] = yield* Effect.all( + [ + read(`${root}/commits/${sha}/check-runs?filter=all&per_page=100`, (response) => { + const decoded = decodeCheckRuns(response.body); + return Option.isNone(decoded) + ? null + : countStates( + decoded.value.check_runs.map((run) => + run.status === "completed" ? (run.conclusion ?? "completed") : run.status, + ), + ); + }), + read(`${root}/commits/${sha}/status?per_page=100`, (response) => { + const decoded = decodeStatus(response.body); + return Option.isNone(decoded) + ? null + : countStates(decoded.value.statuses.map((status) => status.state)); + }), + ], + { concurrency: 2 }, + ); + if (runs === null || statuses === null) return yield* unsupported; + return { + status: [pull.status, runs, statuses].join(" "), + remarks: pull.remarks, + conditional: true, + } satisfies GitHubPullRequestWatchFingerprint; + }).pipe( + Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, { + host, + token, + credentialFingerprint: fingerprint, + }), + Effect.catchTags({ + GitHubApiNotFoundError: () => + Effect.sync(() => { + entry.supported = false; + }).pipe(Effect.andThen(fallback)), + GitHubApiResponseError: (error) => + Effect.sync(() => { + entry.supported &&= !UNSUPPORTED_STATUSES.has(error.status); + }).pipe(Effect.andThen(fallback)), + }), + ), + ); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 0dd188c2a525..c288240a8813 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -936,6 +936,7 @@ export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullReque export interface GitHubPullRequestWatchFingerprint { readonly status: string; readonly remarks: string; + readonly conditional?: true; } type EditedConnection = WatchedPullRequest["comments"] | WatchedPullRequest["reviews"];