diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c77837094b201..224f4cc9655ef 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -69,6 +69,15 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; import { PULL_REQUEST_WATCH_WAKE_LIMIT } from "./pullRequestWatch.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { driver as azureDriver } from "@t3tools/source-control-azure-devops/server/driver"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as PullRequestProviderRegistry from "../pullRequest/PullRequestProviderRegistry.ts"; +import * as PullRequestReadCache from "../pullRequest/PullRequestReadCache.ts"; +import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as KeyValueStore from "effect/persistence/KeyValueStore"; +import { HttpClient } from "effect/http"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; @@ -2893,6 +2902,221 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["approved", "rejected", "status approved", "status rejected", "expired"])( + "wakes once through the Azure provider and service when CI becomes %s", + (scenario) => + Effect.gen(function* () { + const terminal = scenario.endsWith("approved") ? "approved" : "rejected"; + const statusPolicy = scenario.startsWith("status"); + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const projectId = ProjectId.make(`azure-ci-${scenario}`); + const threadId = ThreadId.make(`azure-ci-${scenario}`); + const at = "2026-10-01T00:00:00.000Z"; + const workspaceRoot = "/workspace/azure-ci"; + yield* seedProject({ + projectId, + title: "Azure CI", + workspaceRoot, + defaultModelSelection: null, + createdAt: at, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`azure-create-${scenario}`), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Azure CI", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const repository = "acme/platform/web"; + const url = "https://dev.azure.com/acme/platform/_git/web/pullrequest/42"; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`azure-watch-${scenario}`), + threadId, + host: "dev.azure.com", + repository, + number: 42, + watching: true, + link: { url, source: "agent" }, + }); + let status = "queued"; + const instance = yield* azureDriver.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + SourceControlRateLimit.layer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected HTTP request")), + ), + TestSourceControlHost.layer({ + process: { + run: ({ args }) => { + const resource = args[args.indexOf("--resource") + 1]; + const value = args.includes("account") + ? { user: { name: "agent-user" } } + : args.includes("show") + ? { + pullRequestId: 42, + title: "Azure CI", + status: "active", + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + creationDate: at, + repository: { + name: "web", + project: { id: "project-guid", name: "platform" }, + webUrl: "https://dev.azure.com/acme/platform/_git/web", + }, + } + : resource === "pullRequestIterations" + ? { + value: [ + { + id: 1, + sourceRefCommit: { commitId: "head" }, + commonRefCommit: { commitId: "base" }, + }, + ], + } + : resource === "evaluations" + ? { + value: [ + { + status, + configuration: { + id: 14, + isEnabled: true, + isBlocking: true, + type: { + id: statusPolicy + ? "status-policy" + : "0609b952-1397-4640-95ec-e00a01b2c241", + }, + settings: statusPolicy ? { statusName: "external CI" } : {}, + }, + context: { + lastMergeSourceCommitId: "head", + buildDefinitionName: "CI", + buildId: 123, + isExpired: scenario === "expired" && status === "rejected", + }, + }, + ], + } + : resource === "pullRequestStatuses" && statusPolicy + ? { + value: [ + { id: 1, context: { name: "external CI" }, state: "succeeded" }, + ], + } + : resource === "pullRequestIterationChanges" + ? { changeEntries: [] } + : { value: [] }; + return Effect.succeed( + TestSourceControlHost.processOutput(JSON.stringify(value)), + ); + }, + }, + }), + ), + ), + ); + assert.isNotNull(instance.pullRequests); + if (instance.pullRequests === null) return; + const project = { + id: projectId, + title: "Azure CI", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: at, + updatedAt: at, + repositoryIdentity: { + canonicalKey: `dev.azure.com/${repository}`, + provider: SourceControlProviderKind.make("azure-devops"), + displayName: repository, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://dev.azure.com/acme/platform/_git/web", + }, + }, + }; + const serviceContext = yield* Layer.build( + Layer.mergeAll( + ServerSettings.layerTest(), + Layer.succeed( + PullRequestProviderRegistry.PullRequestProviderRegistry, + PullRequestProviderRegistry.fromProviders([instance.pullRequests]), + ), + Layer.mock(ProjectService.ProjectService)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + get: () => Effect.succeed(instance.sourceControl), + }), + SourceControlRateLimit.layer, + PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(NodeServices.layer), + ), + ), + ); + const service = yield* PullRequestService.make.pipe(Effect.provideContext(serviceContext)); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(PullRequestService.PullRequestService, service), + ); + const records = orchestrator.getThreadRecords(threadId, ["messages", "runs"]); + yield* reactor.sweep; + assert.deepEqual((yield* records).messages, []); + status = "running"; + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual((yield* records).messages, []); + status = terminal; + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + const delivered = yield* records; + assert.equal(delivered.messages.length, 1); + assert.equal( + delivered.messages[0]?.notification?.summary, + terminal === "approved" ? "#42: checks passed" : "#42: checks failed", + ); + assert.equal(delivered.runs.length, 1); + assert.equal(delivered.runs[0]?.status, "starting"); + yield* worker.drain(); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.equal((yield* records).messages.length, 1); + assert.equal((yield* records).runs.length, 1); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`azure-stop-${scenario}`), + threadId, + host: "dev.azure.com", + repository, + number: 42, + watching: false, + }); + }), + ); + it.effect("wakes once for a state it reported, across a restart and a new watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index 4b2e46b5a1e93..99bbdddc43e25 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -48,7 +48,7 @@ const pullRequestRow = { targetRefName: "refs/heads/main", creationDate: "2026-07-01T00:00:00Z", url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", - repository: { name: "web", project: { name: "platform" } }, + repository: { name: "web", project: { name: "platform", id: "project-id" } }, }; const oneIteration = { @@ -110,6 +110,160 @@ afterEach(() => { }); layer("AzureDevOpsPullRequestCli.layer", (it) => { + it.effect.each(["queued", "running", "approved", "rejected"])( + "exposes current-revision Azure CI checks: %s", + (status) => + Effect.gen(function* () { + mockedExecute.mockImplementation(({ args }) => { + const resource = args[args.indexOf("--resource") + 1]; + const value = args.includes("show") + ? pullRequestRow + : resource === "pullRequestIterations" + ? oneIteration + : resource === "evaluations" + ? { + value: [ + { + status, + configuration: { + id: 14, + isEnabled: true, + isBlocking: true, + type: { id: "0609b952-1397-4640-95ec-e00a01b2c241" }, + settings: {}, + }, + context: { + buildId: 123, + buildDefinitionName: "CI", + lastMergeSourceCommitId: "a".repeat(40), + lastMergeCommitId: "merge", + buildIsNotCurrent: false, + isExpired: false, + }, + }, + ], + } + : resource === "pullRequestStatuses" + ? { + value: [ + { + id: 1, + iterationId: 1, + context: { genre: "lint", name: "lint" }, + state: "pending", + }, + { + id: 2, + iterationId: 1, + context: { genre: "lint", name: "lint" }, + state: "succeeded", + }, + { id: 3, iterationId: 0, context: { name: "stale" }, state: "succeeded" }, + ], + } + : { changeEntries: [] }; + return Effect.succeed(output(json(value))); + }); + const provider = yield* AzureDevOpsPullRequestProvider.make; + const input = { cwd: "/w", repository: "platform/web", host: "dev.azure.com", number: 42 }; + const detail = yield* provider.getChangeRequest(input); + expect(detail.checks.map(({ status }) => status)).toEqual([ + status === "approved" ? "success" : status === "rejected" ? "failure" : "pending", + "success", + ]); + expect(detail.checks[0]).toMatchObject({ required: true, name: "CI (policy 14)" }); + expect(detail.checks[0]?.url).toContain("buildId=123"); + expect(provider.getChangeRequestChecks).toBeDefined(); + expect((yield* provider.getChangeRequestChecks!(input)).checks).toEqual(detail.checks); + }), + ); + + it.effect.each([ + "empty", + "pagination", + "authentication", + "malformed", + "push during read", + "repeated cursor", + ])("preserves Azure check read outcomes: %s", (mode) => + Effect.gen(function* () { + let iterationReads = 0; + mockedExecute.mockImplementation(({ args }) => { + const resource = args[args.indexOf("--resource") + 1]; + if (resource === "evaluations" && mode === "authentication") + return Effect.fail( + new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: args.length, + cause: "401", + }), + ); + const empty = { value: [] }; + const status = { id: 1, iterationId: 1, context: { name: "CI" }, state: "succeeded" }; + const pagePolicy = { + status: "queued", + configuration: { + id: 1, + isEnabled: true, + isBlocking: true, + type: { id: "reviewer" }, + settings: {}, + }, + }; + const value = args.includes("show") + ? pullRequestRow + : resource === "pullRequestIterations" + ? ++iterationReads === 2 && mode === "push during read" + ? { value: [{ ...oneIteration.value[0], id: 2 }] } + : oneIteration + : resource === "evaluations" + ? mode === "malformed" + ? { value: [{}] } + : mode === "pagination" && args.includes("$skip=0") + ? { value: Array.from({ length: 100 }, () => pagePolicy) } + : empty + : resource === "pullRequestStatuses" + ? mode === "repeated cursor" + ? { ...empty, continuation_token: "same" } + : mode === "pagination" + ? args.includes("continuationToken=next") + ? { value: [status] } + : { ...empty, continuation_token: "next" } + : empty + : { changeEntries: [] }; + return Effect.succeed(output(json(value))); + }); + const provider = yield* AzureDevOpsPullRequestProvider.make; + const result = yield* provider.getChangeRequestChecks!({ + cwd: "/w", + repository: "platform/web", + host: "dev.azure.com", + number: 42, + }).pipe(Effect.result); + if (mode === "empty" || mode === "pagination") { + assert.isTrue(result._tag === "Success"); + if (result._tag !== "Success") return; + expect(result.success.checks).toHaveLength(mode === "empty" ? 0 : 1); + if (mode === "pagination") { + expect(mockedExecute.mock.calls.some(([call]) => call.args.includes("$skip=100"))).toBe( + true, + ); + expect( + mockedExecute.mock.calls.some(([call]) => call.args.includes("continuationToken=next")), + ).toBe(true); + } + } else { + assert.isTrue(result._tag === "Failure"); + if (result._tag !== "Failure") return; + expect(result.failure.reason).toBe( + mode === "authentication" ? "unauthenticated" : "failed", + ); + } + }), + ); + it.effect("reports the latest pushed source commit even while the trial merge is stale", () => Effect.gen(function* () { const previousHead = "a".repeat(40); @@ -136,7 +290,20 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ), ), ) - .mockReturnValueOnce(Effect.succeed(output(json({ changeEntries: [] })))); + .mockReturnValueOnce(Effect.succeed(output(json({ changeEntries: [] })))) + .mockReturnValueOnce(Effect.succeed(output(json({ value: [] })))) + .mockReturnValueOnce(Effect.succeed(output(json({ value: [] })))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { ...oneIteration.value[0], id: 2, sourceRefCommit: { commitId: latestHead } }, + ], + }), + ), + ), + ); const provider = yield* AzureDevOpsPullRequestProvider.make; const detail = yield* provider.getChangeRequest({ cwd: "/w", @@ -147,7 +314,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { expect(detail.headSha).toBe(latestHead); expect(argsOfCall(2)).toContain("iterationId=2"); - expect(mockedExecute).toHaveBeenCalledTimes(3); + expect(mockedExecute).toHaveBeenCalledTimes(6); }), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 5856d7c0beadf..cbbf0daceb01a 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -12,6 +12,12 @@ import type { } from "@t3tools/contracts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import { + decodePolicyEvaluationsJson, + decodeStatusesJson, + type AzureDevOpsPolicyEvaluation, + type AzureDevOpsStatus, +} from "./azureDevOpsChecks.ts"; import { decodeItemContentJson, decodeIterationChangesJson, @@ -157,6 +163,18 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + readonly listPolicyEvaluations: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + readonly listStatuses: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + readonly listThreads: (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; @@ -296,6 +314,8 @@ export const make = Effect.gen(function* () { readonly cwd: string; readonly operation: string; readonly resource: string; + readonly area?: string; + readonly apiVersion?: string; readonly routeParameters: ReadonlyArray; readonly queryParameters?: ReadonlyArray; readonly maxOutputBytes?: number; @@ -309,11 +329,11 @@ export const make = Effect.gen(function* () { "invoke", ...detectArgs, "--area", - "git", + input.area ?? "git", "--resource", input.resource, "--api-version", - REST_API_VERSION, + input.apiVersion ?? REST_API_VERSION, "--route-parameters", ...input.routeParameters, ...(input.queryParameters === undefined @@ -545,6 +565,71 @@ export const make = Effect.gen(function* () { ); }), + listPolicyEvaluations: Effect.fn("AzureDevOpsPullRequestCli.listPolicyEvaluations")( + function* (input) { + if (!input.location.projectId) + return yield* new AzureDevOpsPullRequestIncompleteError({ + command: "az", + cwd: input.cwd, + number: input.number, + }); + const policies: AzureDevOpsPolicyEvaluation[] = []; + for (let skip = 0; skip < 10_000; skip += 100) { + const page = yield* invoke({ + cwd: input.cwd, + operation: "listPolicyEvaluations", + area: "policy", + resource: "evaluations", + apiVersion: "7.1-preview", + routeParameters: [`project=${input.location.project}`], + queryParameters: [ + `artifactId=vstfs:///CodeReview/CodeReviewId/${input.location.projectId}/${input.number}`, + "$top=100", + `$skip=${skip}`, + ], + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + decode: decodePolicyEvaluationsJson, + }); + policies.push(...page.value); + if (page.value.length < 100) return policies; + } + return yield* new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listPolicyEvaluations", + cause: "Policy pagination exceeded the read limit", + }); + }, + ), + + listStatuses: Effect.fn("AzureDevOpsPullRequestCli.listStatuses")(function* (input) { + const statuses: AzureDevOpsStatus[] = []; + let token: string | undefined; + const seen = new Set(); + for (let pageNumber = 0; pageNumber < 100; pageNumber++) { + const page = yield* invoke({ + cwd: input.cwd, + operation: "listStatuses", + resource: "pullRequestStatuses", + routeParameters: pullRequestRoute(input), + ...(token === undefined ? {} : { queryParameters: [`continuationToken=${token}`] }), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + decode: decodeStatusesJson, + }); + statuses.push(...page.value); + if (!page.continuation_token) return statuses; + if (seen.has(page.continuation_token)) break; + token = page.continuation_token; + seen.add(token); + } + return yield* new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listStatuses", + cause: "Status pagination did not complete", + }); + }), + listThreads: Effect.fn("AzureDevOpsPullRequestCli.listThreads")(function (input) { return invoke({ cwd: input.cwd, diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts index 345d74de84ba1..3319bd5925c4a 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts @@ -129,6 +129,8 @@ describe("getChangeRequest", () => { Effect.provide( Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ getPullRequest: () => Effect.succeed(PULL_REQUEST), + listPolicyEvaluations: () => Effect.succeed([]), + listStatuses: () => Effect.succeed([]), listIterations: () => Effect.succeed([ITERATION, { ...ITERATION, id: 4, headCommit: "new-head" }]), listIterationChanges: () => @@ -156,7 +158,7 @@ describe("getChangeRequest", () => { ); it.effect.each(["missing", "failed"] as const)( - "leaves the head unavailable when the iteration read is %s", + "fails checks rather than returning an empty success when the iteration read is %s", (state) => Effect.gen(function* () { const provider = yield* make.pipe( @@ -177,14 +179,15 @@ describe("getChangeRequest", () => { }), ), ); - const detail = yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "dev.azure.com", - number: 7, - }); - expect(detail.headSha).toBeUndefined(); - expect(detail.changedFiles).toBe(0); + const error = yield* Effect.flip( + provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + }), + ); + expect(error.reason).toBe("failed"); }), ); }); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index a1d3ddb6b8c96..48d9f76d2ee2a 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -4,6 +4,8 @@ import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { azureDevOpsChecks } from "./azureDevOpsChecks.ts"; +import type { PullRequestChecksState } from "@t3tools/contracts"; import { azureDevOpsFilePatch, azureDevOpsUnreadableFilePatch, @@ -139,6 +141,48 @@ export const make = Effect.gen(function* () { }), ); + const readChecks = Effect.fn("AzureDevOpsPullRequestProvider.readChecks")(function* ( + input: { readonly cwd: string; readonly number: number }, + pullRequest: AzureDevOpsPullRequest, + iterations: ReadonlyArray, + ) { + const location = pullRequest.location; + const latest = iterations.at(-1); + if (location === null || latest === undefined) + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestIncompleteError({ + command: "az", + cwd: input.cwd, + number: input.number, + }); + const policies = yield* cli.listPolicyEvaluations({ ...input, location }); + const statuses = yield* cli.listStatuses({ ...input, location }); + const after = yield* cli.listIterations({ ...input, location }); + if (after.at(-1)?.id !== latest.id || after.at(-1)?.headCommit !== latest.headCommit) { + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "readChecks", + cause: "PR revision changed while reading checks", + }); + } + const checks = azureDevOpsChecks({ + policies, + statuses, + headSha: latest.headCommit, + iterationId: latest.id, + url: pullRequest.url, + }); + const state: PullRequestChecksState | null = + checks.length === 0 + ? null + : checks.some((check) => ["failure", "cancelled", "action-required"].includes(check.status)) + ? "failing" + : checks.some((check) => check.status === "pending") + ? "pending" + : "passing"; + return { checks, state: pullRequest.state, checksState: state }; + }); + const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; // PR locations are immutable; caching avoids full PR reads for viewed-file checks. @@ -307,6 +351,21 @@ export const make = Effect.gen(function* () { })), ), + getChangeRequestChecks: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequestChecks")( + function* (input) { + const pullRequest = yield* cli.getPullRequest(input); + if (pullRequest.location === null) + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestIncompleteError({ + command: "az", + cwd: input.cwd, + number: input.number, + }); + const iterations = yield* cli.listIterations({ ...input, location: pullRequest.location }); + return yield* readChecks(input, pullRequest, iterations); + }, + Effect.mapError(fail("getChangeRequestChecks")), + ), + getChangeRequest: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequest")( function* (input) { const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); @@ -314,9 +373,7 @@ export const make = Effect.gen(function* () { const iterations = location === null ? [] - : yield* cli - .listIterations({ cwd: input.cwd, location, number: input.number }) - .pipe(Effect.orElseSucceed(() => [])); + : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }); // The trial merge's lastMergeSourceCommit can lag behind a new source push. const headSha = iterations.at(-1)?.headCommit; const changedFiles = @@ -331,6 +388,7 @@ export const make = Effect.gen(function* () { Effect.map((listed) => listed.changes.length), Effect.orElseSucceed(() => 0), ); + const { checks, checksState } = yield* readChecks(input, pullRequest, iterations); const detail: ProviderChangeRequestDetail = { ...toChangeRequest(pullRequest), body: pullRequest.body, @@ -339,7 +397,8 @@ export const make = Effect.gen(function* () { mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, reviewers: pullRequest.reviewers, - checks: [], + checks, + checksState, mergeCapabilities: { merge: true, squash: true, rebase: false }, viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, autoMergeEnabled: pullRequest.autoMergeEnabled, diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsChecks.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsChecks.test.ts new file mode 100644 index 0000000000000..38fc3df7e1b13 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsChecks.test.ts @@ -0,0 +1,143 @@ +import { expect, it } from "@effect/vitest"; +import { azureDevOpsChecks, type AzureDevOpsPolicyEvaluation } from "./azureDevOpsChecks.ts"; + +const policy: AzureDevOpsPolicyEvaluation = { + status: "approved", + configuration: { + id: 14, + isEnabled: true, + isBlocking: true, + type: { id: "0609b952-1397-4640-95ec-e00a01b2c241" }, + settings: {}, + }, + context: { buildDefinitionName: "CI", buildId: 123, lastMergeSourceCommitId: "head" }, +}; + +it.each([ + { label: "old head", context: { ...policy.context, lastMergeSourceCommitId: "old" } }, + { label: "not current", context: { ...policy.context, buildIsNotCurrent: true } }, + { label: "no revision proof", context: null }, +])("keeps $label builds pending", ({ context }) => { + const checks = azureDevOpsChecks({ + policies: [{ ...policy, context }], + statuses: [], + headSha: "head", + iterationId: 2, + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", + }); + expect(checks[0]?.status).toBe("pending"); +}); + +it("keeps optional CI separate from non-CI policies and accepts repositories without checks", () => { + const input = { + statuses: [], + headSha: "head", + iterationId: 2, + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", + }; + expect(azureDevOpsChecks({ ...input, policies: [] })).toEqual([]); + const optional = { ...policy, configuration: { ...policy.configuration, isBlocking: false } }; + const checks = azureDevOpsChecks({ + ...input, + policies: [ + optional, + { ...policy, configuration: { ...policy.configuration, type: { id: "reviewer" } } }, + ], + }); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ required: false, status: "success" }); +}); + +it("uses the latest status of the current iteration ahead of old or unscoped successes", () => { + const context = { genre: "lint", name: "check" }; + const checks = azureDevOpsChecks({ + policies: [], + headSha: "head", + iterationId: 2, + url: "https://example.test/pr/42", + statuses: [ + { id: 9, context, state: "succeeded", iterationId: 1 }, + { id: 10, context, state: "succeeded" }, + { id: 3, context, state: "failed", iterationId: 2 }, + { id: 2, context, state: "pending", iterationId: 2 }, + ], + }); + expect(checks).toHaveLength(1); + expect(checks[0]?.status).toBe("failure"); +}); + +it.each(["approved", "rejected", "broken", "queued", "notApplicable"])( + "uses the %s status-policy evaluation even when the status says success", + (evaluation) => { + const checks = azureDevOpsChecks({ + policies: [ + { + ...policy, + status: evaluation, + context: null, + configuration: { + ...policy.configuration, + type: { id: "status-policy" }, + settings: { statusName: "CI", statusGenre: "external" }, + }, + }, + ], + statuses: [ + { + id: 1, + context: { name: "CI", genre: "external" }, + state: "succeeded", + description: "CI result", + targetUrl: "https://ci.example.test/build/1", + }, + ], + headSha: "head", + iterationId: 2, + url: "https://example.test/pr/42", + }); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + status: + evaluation === "approved" + ? "success" + : evaluation === "rejected" + ? "failure" + : evaluation === "broken" + ? "action-required" + : evaluation === "notApplicable" + ? "skipped" + : "pending", + description: "CI result", + url: "https://ci.example.test/build/1", + required: true, + }); + }, +); + +it("includes statuses applying to the whole PR when no policy covers them", () => { + const checks = azureDevOpsChecks({ + policies: [], + statuses: [{ id: 1, context: { name: "CI" }, state: "succeeded" }], + headSha: "head", + iterationId: 2, + url: "https://example.test/pr/42", + }); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ status: "success", required: false }); +}); + +it.each(["queued", "running", "approved", "rejected"])( + "reports expiration for a current %s build", + (status) => { + const checks = azureDevOpsChecks({ + policies: [{ ...policy, status, context: { ...policy.context, isExpired: true } }], + statuses: [], + headSha: "head", + iterationId: 2, + url: "https://example.test/pr/42", + }); + expect(checks[0]?.status).toBe( + status === "queued" || status === "running" ? "pending" : "failure", + ); + }, +); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsChecks.ts b/packages/source-control-azure-devops/src/server/azureDevOpsChecks.ts new file mode 100644 index 0000000000000..2fe7ca18f8573 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsChecks.ts @@ -0,0 +1,157 @@ +import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +const optionalString = Schema.optional(Schema.NullOr(Schema.String)); +const optionalBoolean = Schema.optional(Schema.NullOr(Schema.Boolean)); +const PolicySchema = Schema.Struct({ + status: Schema.String, + configuration: Schema.Struct({ + id: Schema.Int, + isEnabled: Schema.Boolean, + isBlocking: Schema.Boolean, + type: Schema.Struct({ id: Schema.String }), + settings: Schema.Struct({ + displayName: optionalString, + statusName: optionalString, + statusGenre: optionalString, + }), + }), + context: Schema.optional( + Schema.NullOr( + Schema.Struct({ + buildId: Schema.optional(Schema.NullOr(Schema.Int)), + buildDefinitionName: optionalString, + lastMergeSourceCommitId: optionalString, + buildIsNotCurrent: optionalBoolean, + isExpired: optionalBoolean, + }), + ), + ), +}); +const StatusSchema = Schema.Struct({ + id: Schema.Int, + iterationId: Schema.optional(Schema.NullOr(Schema.Int)), + context: Schema.Struct({ name: Schema.String, genre: optionalString }), + state: optionalString, + description: optionalString, + targetUrl: optionalString, +}); + +export const decodePolicyEvaluationsJson = decodeJsonResult( + Schema.Struct({ value: Schema.Array(PolicySchema) }), +); +export const decodeStatusesJson = decodeJsonResult( + Schema.Struct({ + value: Schema.Array(StatusSchema), + continuation_token: optionalString, + }), +); + +export type AzureDevOpsPolicyEvaluation = typeof PolicySchema.Type; +export type AzureDevOpsStatus = typeof StatusSchema.Type; + +function policyStatus(status: string): PullRequestCheckStatus { + switch (status.toLowerCase()) { + case "approved": + return "success"; + case "rejected": + return "failure"; + case "broken": + return "action-required"; + case "notapplicable": + return "skipped"; + default: + return "pending"; + } +} + +function statusState(status: string | null | undefined): PullRequestCheckStatus { + switch (status?.toLowerCase()) { + case "succeeded": + return "success"; + case "failed": + case "error": + return "failure"; + case "notapplicable": + return "skipped"; + default: + return "pending"; + } +} + +const statusKey = (context: { + readonly name: string; + readonly genre?: string | null | undefined; +}) => JSON.stringify([context.genre ?? "", context.name]); + +export function azureDevOpsChecks(input: { + readonly policies: ReadonlyArray; + readonly statuses: ReadonlyArray; + readonly headSha: string; + readonly iterationId: number; + readonly url: string; +}): ReadonlyArray { + const checks: PullRequestCheck[] = []; + const covered = new Set(); + const latest = new Map(); + for (const status of input.statuses) { + if (status.iterationId != null && status.iterationId !== input.iterationId) continue; + const key = statusKey(status.context); + const previous = latest.get(key); + if (previous?.iterationId != null && status.iterationId == null) continue; + if ( + previous === undefined || + (previous.iterationId == null && status.iterationId != null) || + previous.id < status.id + ) + latest.set(key, status); + } + for (const policy of input.policies) { + const { configuration, context } = policy; + if (!configuration.isEnabled) continue; + const build = configuration.type.id.toLowerCase() === "0609b952-1397-4640-95ec-e00a01b2c241"; + const statusName = configuration.settings.statusName; + if (!build && !statusName) continue; + const currentStatus = statusName + ? latest.get(statusKey({ name: statusName, genre: configuration.settings.statusGenre })) + : undefined; + if (statusName) + covered.add(statusKey({ name: statusName, genre: configuration.settings.statusGenre })); + const stale = + build && + policy.status.toLowerCase() !== "notapplicable" && + (context?.lastMergeSourceCommitId !== input.headSha || context?.buildIsNotCurrent === true); + const expired = + build && + context?.isExpired === true && + !["queued", "running", "notapplicable"].includes(policy.status.toLowerCase()); + const label = + configuration.settings.displayName || context?.buildDefinitionName || statusName || "Build"; + checks.push({ + name: `${label} (policy ${configuration.id})`, + status: stale ? "pending" : expired ? "failure" : policyStatus(policy.status), + required: configuration.isBlocking, + description: stale + ? "Waiting for a check of the current PR revision." + : expired + ? "Build validation expired." + : (currentStatus?.description ?? null), + url: + build && (context?.buildId ?? 0) > 0 + ? `${input.url.split("/_git/")[0]}/_build/results?buildId=${context?.buildId}` + : (currentStatus?.targetUrl ?? input.url), + }); + } + for (const [key, status] of [...latest].sort(([left], [right]) => left.localeCompare(right))) { + if (covered.has(key)) continue; + checks.push({ + name: `Status ${statusKey(status.context)}`, + status: statusState(status.state), + required: false, + description: status.description ?? null, + url: status.targetUrl ?? null, + }); + } + return checks; +} diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts index 1073a2f96be87..36ab6d1bd2599 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts @@ -55,7 +55,12 @@ const RawPullRequestSchema = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), webUrl: Schema.optional(Schema.NullOr(Schema.String)), project: Schema.optional( - Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + id: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), ), }), ), @@ -105,6 +110,7 @@ const RawViewerSchema = Schema.Struct({ export interface AzureDevOpsRepositoryLocation { readonly project: string; + readonly projectId?: string; readonly repository: string; } @@ -167,7 +173,8 @@ function toLocation( const project = trimmed(raw.repository?.project?.name); const repository = trimmed(raw.repository?.name); if (project === null || repository === null) return null; - return { project, repository }; + const projectId = trimmed(raw.repository?.project?.id); + return { project, repository, ...(projectId === null ? {} : { projectId }) }; } function toAutoMergeMethod(