From f6c816e1b025e67d75fb5e274025f50fd67c0dcf Mon Sep 17 00:00:00 2001 From: Alex Vallone <39172404+amv146@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:44:14 -0700 Subject: [PATCH] feat(azure-devops): report pull request checks Azure DevOps pull requests showed no checks. Read branch policy evaluations and the statuses posted on the pull request, and map them to checks for the Checks panel and the pull request detail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../server/AzureDevOpsPullRequestCli.test.ts | 44 ++- .../src/server/AzureDevOpsPullRequestCli.ts | 65 +++++ .../AzureDevOpsPullRequestProvider.test.ts | 120 +++++++- .../server/AzureDevOpsPullRequestProvider.ts | 87 +++++- .../server/azureDevOpsPullRequestJson.test.ts | 246 +++++++++++++++++ .../src/server/azureDevOpsPullRequestJson.ts | 261 ++++++++++++++++++ 6 files changed, 810 insertions(+), 13 deletions(-) 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 9bbe1468f8f54..212f87d2657f9 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -136,7 +136,9 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ), ), ) - .mockReturnValueOnce(Effect.succeed(output(json({ changeEntries: [] })))); + .mockReturnValueOnce(Effect.succeed(output(json({ changeEntries: [] })))) + .mockReturnValueOnce(Effect.succeed(output("[]"))) + .mockReturnValueOnce(Effect.succeed(output(json({ value: [] })))); const provider = yield* AzureDevOpsPullRequestProvider.make; const detail = yield* provider.getChangeRequest({ cwd: "/w", @@ -147,7 +149,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { expect(detail.headSha).toBe(latestHead); expect(argsOfCall(2)).toContain("iterationId=2"); - expect(mockedExecute).toHaveBeenCalledTimes(3); + expect(mockedExecute).toHaveBeenCalledTimes(5); }), ); @@ -889,6 +891,44 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("reads a pull request's policies, and nothing when none apply", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const evaluations = yield* cli.listPolicyEvaluations({ cwd: "/w", number: 42 }); + + expect(evaluations).toEqual([]); + expect(argsOfCall(0).slice(0, 4)).toEqual(["repos", "pr", "policy", "list"]); + expect(argsOfCall(0)).toContain("42"); + }), + ); + + it.effect("reads only the status fields the checks use", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [{ id: 1, state: "succeeded", description: "Build", context: { name: "ci" } }], + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const statuses = yield* cli.listPullRequestStatuses({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + expect(statuses.map((status) => status.description)).toEqual(["Build"]); + // A status carries every identity Azure knows of it, which is most of a large response. + expect(argsOfCall(0)).toContain("--query"); + }), + ); + it.effect("refuses a reviewer az would read as a flag, before running anything", () => Effect.gen(function* () { const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 81c9b2e68fb6e..435d2bb5f89b5 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -16,14 +16,18 @@ import { decodeItemContentJson, decodeIterationChangesJson, decodeIterationsJson, + decodePolicyEvaluationsJson, decodePullRequestJson, decodePullRequestListJson, + decodePullRequestStatusesJson, decodeThreadsJson, decodeViewerJson, type AzureDevOpsChangeEntry, type AzureDevOpsItemContent, type AzureDevOpsIteration, + type AzureDevOpsPolicyEvaluation, type AzureDevOpsPullRequest, + type AzureDevOpsPullRequestStatus, type AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; @@ -110,6 +114,9 @@ const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; /** Review history is unpaged and independent of diff size. */ const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +/** The status fields the Checks list reads, in the `{ value }` page shape statuses arrive in. */ +const PULL_REQUEST_STATUS_FIELDS = + "{value:value[].{id:id,state:state,description:description,targetUrl:targetUrl,context:context}}"; /** Azure's iteration-change page limit. */ const CHANGE_ENTRIES_PER_PAGE = 2000; @@ -157,6 +164,22 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + /** Branch policy evaluations, which need only the number, so they can read alongside it. */ + readonly listPolicyEvaluations: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** Statuses external services posted, which Azure's Checks list shows beside policies. */ + readonly listPullRequestStatuses: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect< + ReadonlyArray, + AzureDevOpsPullRequestCliError + >; + readonly listThreads: (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; @@ -299,6 +322,8 @@ export const make = Effect.gen(function* () { readonly routeParameters: ReadonlyArray; readonly queryParameters?: ReadonlyArray; readonly maxOutputBytes?: number; + /** A JMESPath projection `az` applies before printing, to keep large responses small. */ + readonly jmesQuery?: string; readonly decode: (raw: string) => Result.Result; }): Effect.Effect => executeJson({ @@ -319,6 +344,7 @@ export const make = Effect.gen(function* () { ...(input.queryParameters === undefined ? [] : ["--query-parameters", ...input.queryParameters]), + ...(input.jmesQuery === undefined ? [] : ["--query", input.jmesQuery]), ], }).pipe( Effect.flatMap((result) => { @@ -545,6 +571,45 @@ export const make = Effect.gen(function* () { ); }), + listPolicyEvaluations: Effect.fn("AzureDevOpsPullRequestCli.listPolicyEvaluations")( + function (input) { + return executeJson({ + cwd: input.cwd, + args: ["repos", "pr", "policy", "list", ...detectArgs, "--id", String(input.number)], + }).pipe( + Effect.flatMap((result) => { + const decoded = decodePolicyEvaluationsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listPolicyEvaluations", + cause: decoded.failure, + }), + ); + }), + ); + }, + ), + + listPullRequestStatuses: Effect.fn("AzureDevOpsPullRequestCli.listPullRequestStatuses")( + function (input) { + return invoke({ + cwd: input.cwd, + operation: "listPullRequestStatuses", + resource: "pullRequestStatuses", + routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + // Each status carries its creator's profile and links, several times the fields read + // here, which costs `az` seconds to print on a busy pull request. + jmesQuery: PULL_REQUEST_STATUS_FIELDS, + decode: decodePullRequestStatusesJson, + }); + }, + ), + 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..1a1a009bf2fb1 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts @@ -15,7 +15,10 @@ import { MAX_FILE_DIFF_EDITS, parseAzureDevOpsDiffCursor, } from "./azureDevOpsDiff.ts"; -import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; +import type { + AzureDevOpsChangeEntry, + AzureDevOpsPolicyEvaluation, +} from "./azureDevOpsPullRequestJson.ts"; const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; @@ -39,6 +42,25 @@ const PULL_REQUEST = { autoMergeEnabled: false, }; +const CI_EVALUATION: AzureDevOpsPolicyEvaluation = { + status: "approved", + configuration: { + isBlocking: true, + type: { id: "0609b952-1397-4640-95ec-e00a01b2c241", displayName: "Build" }, + settings: { displayName: "CI" }, + }, +}; + +const CI_CHECK = { name: "CI", status: "success", description: null, url: null, required: true }; + +const readError = (operation: string) => + new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ + command: "az", + cwd: "/w", + operation, + cause: "denied", + }); + function change( path: string, changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", @@ -140,6 +162,8 @@ describe("getChangeRequest", () => { cause: "unavailable", }), ), + listPolicyEvaluations: () => Effect.succeed([]), + listPullRequestStatuses: () => Effect.succeed([]), }), ), ); @@ -174,6 +198,8 @@ describe("getChangeRequest", () => { cause: "unavailable", }), ), + listPolicyEvaluations: () => Effect.succeed([]), + listPullRequestStatuses: () => Effect.succeed([]), }), ), ); @@ -187,6 +213,98 @@ describe("getChangeRequest", () => { expect(detail.changedFiles).toBe(0); }), ); + + it.effect("carries the policy checks, and still answers when they cannot be read", () => + Effect.gen(function* () { + let policyReadFails = false; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts")], truncated: false }), + listPolicyEvaluations: () => + policyReadFails + ? Effect.fail(readError("listPolicyEvaluations")) + : Effect.succeed([CI_EVALUATION]), + listPullRequestStatuses: () => Effect.succeed([]), + }), + ), + ); + const input = { cwd: "/w", repository: "acme/web", host: "dev.azure.com", number: 7 }; + + const checkedDetail = yield* provider.getChangeRequest(input); + expect(checkedDetail.checks).toEqual([CI_CHECK]); + expect(checkedDetail.headSha).toBe(ITERATION.headCommit); + expect(checkedDetail.changedFiles).toBe(1); + + policyReadFails = true; + const detail = yield* provider.getChangeRequest(input); + expect(detail.checks).toEqual([]); + expect(detail.changedFiles).toBe(1); + }), + ); +}); + +describe("getChangeRequestChecks", () => { + const readChecksOf = (provider: Effect.Success) => { + const readChecks = provider.getChangeRequestChecks; + if (readChecks === undefined) return Effect.die("checks read was not implemented"); + return readChecks({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number: 7 }); + }; + + it.effect("reports the policy and status checks alongside the pull request's state", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed({ ...PULL_REQUEST, state: "merged" as const }), + listPolicyEvaluations: () => Effect.succeed([CI_EVALUATION]), + listPullRequestStatuses: ({ location }) => + Effect.succeed( + location.repository === "web" + ? [ + { + id: 1, + key: "lint/eslint", + state: "succeeded", + description: "Lint", + url: null, + }, + ] + : [], + ), + }), + ), + ); + + expect(yield* readChecksOf(provider)).toEqual({ + state: "merged", + checks: [ + CI_CHECK, + { name: "Lint", status: "success", description: null, url: null, required: false }, + ], + }); + }), + ); + + it.effect("keeps the policy checks when the statuses cannot be read", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listPolicyEvaluations: () => Effect.succeed([CI_EVALUATION]), + listPullRequestStatuses: () => Effect.fail(readError("listPullRequestStatuses")), + }), + ), + ); + + expect(yield* readChecksOf(provider)).toEqual({ state: "open", checks: [CI_CHECK] }); + }), + ); }); describe("getDiff reads", () => { diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index dbd9ae94875bc..8df4e53237b52 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -26,12 +26,13 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "@t3tools/source-control-core/server/PullRequestProvider"; -import type { - AzureDevOpsChangeEntry, - AzureDevOpsItemContent, - AzureDevOpsIteration, - AzureDevOpsPullRequest, - AzureDevOpsRepositoryLocation, +import { + toPullRequestChecks, + type AzureDevOpsChangeEntry, + type AzureDevOpsItemContent, + type AzureDevOpsIteration, + type AzureDevOpsPullRequest, + type AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; import { definition } from "../shared/definition.ts"; @@ -148,6 +149,31 @@ export const make = Effect.gen(function* () { cause: error, }); + /** + * The statuses posted on a pull request. They only name and link what its policies already + * report, so a failed read leaves the policy checks as they are rather than losing them. + */ + const listStatuses = Effect.fn("AzureDevOpsPullRequestProvider.listStatuses")(function (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation | null; + readonly number: number; + }) { + return input.location === null + ? Effect.succeed([]) + : cli + .listPullRequestStatuses({ + cwd: input.cwd, + location: input.location, + number: input.number, + }) + .pipe( + Effect.tapError(() => + Effect.logWarning("Azure DevOps pull request statuses could not be read"), + ), + Effect.orElseSucceed(() => []), + ); + }); + const unsupported = (operation: string) => Effect.fail( new PullRequestProviderError({ @@ -312,6 +338,29 @@ export const make = Effect.gen(function* () { .getPullRequest({ cwd: input.cwd, number: input.number }) .pipe(Effect.mapError(fail("getChangeRequestSummary")), Effect.map(toSummary)), + getChangeRequestChecks: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequestChecks")( + function* (input) { + // Policies need only the number, so they read while the pull request is still loading. + const [{ pullRequest, statuses }, evaluations] = yield* Effect.all( + [ + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.flatMap((pullRequest) => + listStatuses({ + cwd: input.cwd, + location: pullRequest.location, + number: input.number, + }).pipe(Effect.map((statuses) => ({ pullRequest, statuses }))), + ), + ), + cli.listPolicyEvaluations({ cwd: input.cwd, number: input.number }), + ], + { concurrency: "unbounded" }, + ); + return { state: pullRequest.state, checks: toPullRequestChecks(evaluations, statuses) }; + }, + Effect.mapError(fail("getChangeRequestChecks")), + ), + getChangeRequest: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequest")( function* (input) { const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); @@ -324,10 +373,10 @@ export const make = Effect.gen(function* () { .pipe(Effect.orElseSucceed(() => [])); // The trial merge's lastMergeSourceCommit can lag behind a new source push. const headSha = iterations.at(-1)?.headCommit; - const changedFiles = + const countChangedFiles = location === null - ? 0 - : yield* listLatestChanges({ + ? Effect.succeed(0) + : listLatestChanges({ cwd: input.cwd, location, number: input.number, @@ -336,6 +385,24 @@ export const make = Effect.gen(function* () { Effect.map((listed) => listed.changes.length), Effect.orElseSucceed(() => 0), ); + // Checks are refreshed on their own path too, so a failed read here shows none for now + // rather than losing the detail. + const readChecks = Effect.all( + [ + cli.listPolicyEvaluations({ cwd: input.cwd, number: input.number }), + listStatuses({ cwd: input.cwd, location, number: input.number }), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([evaluations, statuses]) => toPullRequestChecks(evaluations, statuses)), + Effect.tapError(() => + Effect.logWarning("Azure DevOps pull request checks could not be read"), + ), + Effect.orElseSucceed(() => []), + ); + const [changedFiles, checks] = yield* Effect.all([countChangedFiles, readChecks], { + concurrency: "unbounded", + }); const detail: ProviderChangeRequestDetail = { ...toChangeRequest(pullRequest), body: pullRequest.body, @@ -344,7 +411,7 @@ 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, 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/azureDevOpsPullRequestJson.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts index b9b1e36a94349..75746ea0881de 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts @@ -5,9 +5,13 @@ import { decodeItemContentJson, decodeIterationChangesJson, decodeIterationsJson, + decodePolicyEvaluationsJson, + decodePullRequestStatusesJson, decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, + toPullRequestChecks, + type AzureDevOpsPullRequestStatus, } from "./azureDevOpsPullRequestJson.ts"; const REST_URL = @@ -516,3 +520,245 @@ describe("what az devops invoke answers with", () => { ).toEqual({ contents: "# T3Demo\n", isBinary: false }); }); }); + +const POLICY_URL = + "https://dev.azure.com/acme/6f9c9b7f-0000-0000-0000-000000000000/_apis/policy/configurations/12"; + +/** Shaped after Azure's `PolicyEvaluationRecord`, trimmed to the fields that are read. */ +function policy(input: { + readonly status: string; + readonly typeId?: string; + readonly typeName?: string; + readonly settings?: Record; + readonly context?: Record | null; + readonly configuration?: Record; +}): Record { + return { + status: input.status, + configuration: { + isBlocking: true, + isEnabled: true, + isDeleted: false, + url: POLICY_URL, + type: { + id: input.typeId ?? "0609b952-1397-4640-95ec-e00a01b2c241", + displayName: input.typeName ?? "Build", + }, + settings: input.settings ?? {}, + ...input.configuration, + }, + context: input.context ?? null, + }; +} + +const STATUS_TYPE_ID = "cbdc66da-9728-4af8-aada-9a5a32e4a226"; +const REVIEWERS_TYPE_ID = "fa4e907d-c16b-4a4c-9dfa-4906e5d171dd"; + +describe("decodePolicyEvaluationsJson", () => { + it("skips disabled, deleted and malformed policies", () => { + const evaluations = expectSuccess( + decodePolicyEvaluationsJson( + JSON.stringify([ + policy({ status: "approved", configuration: { isEnabled: false } }), + policy({ status: "approved", configuration: { isDeleted: true } }), + { status: "approved" }, + "not a policy", + policy({ status: "approved", settings: { displayName: "Kept" } }), + ]), + ), + ); + + expect(evaluations.map((evaluation) => evaluation.configuration.settings?.displayName)).toEqual( + ["Kept"], + ); + }); + + it("treats empty output as no checks and rejects output that is not a list", () => { + expect(expectSuccess(decodePolicyEvaluationsJson(""))).toEqual([]); + expect(Result.isFailure(decodePolicyEvaluationsJson("{}"))).toBe(true); + }); +}); + +function checksFor( + policies: ReadonlyArray, + statuses: ReadonlyArray = [], +) { + return toPullRequestChecks( + expectSuccess(decodePolicyEvaluationsJson(JSON.stringify(policies))), + statuses, + ); +} + +describe("toPullRequestChecks", () => { + it("maps build and status policies to checks and leaves review policies out", () => { + const checks = checksFor([ + policy({ + status: "approved", + settings: { displayName: "CI" }, + context: { buildId: 101, buildDefinitionName: "web-ci" }, + }), + policy({ + status: "running", + context: { buildId: 102, buildDefinitionName: "web-e2e" }, + configuration: { isBlocking: false }, + }), + policy({ + status: "queued", + typeId: STATUS_TYPE_ID, + typeName: "Status", + settings: { statusName: "license-scan" }, + }), + policy({ + status: "rejected", + typeId: REVIEWERS_TYPE_ID, + typeName: "Minimum number of reviewers", + }), + ]); + + expect(checks).toEqual([ + { + name: "CI", + status: "success", + description: null, + url: "https://dev.azure.com/acme/6f9c9b7f-0000-0000-0000-000000000000/_build/results?buildId=101", + required: true, + }, + { + name: "license-scan", + status: "pending", + description: null, + url: null, + required: true, + }, + { + name: "web-e2e", + status: "pending", + description: null, + url: "https://dev.azure.com/acme/6f9c9b7f-0000-0000-0000-000000000000/_build/results?buildId=102", + required: false, + }, + ]); + }); + + it("names a status policy after the status it last saw, as Azure does", () => { + const statuses = expectSuccess( + decodePullRequestStatusesJson( + JSON.stringify({ + value: [ + { + id: 4, + state: "failed", + description: "Security review", + targetUrl: "https://example.com/security-review", + context: { genre: "security", name: "review" }, + }, + ], + }), + ), + ); + const [check] = checksFor( + [ + policy({ + status: "rejected", + typeId: STATUS_TYPE_ID, + typeName: "Status", + settings: { statusName: "review", defaultDisplayName: "Review" }, + context: { latestStatusId: 4 }, + }), + ], + statuses, + ); + + expect(check).toEqual({ + name: "Security review", + status: "failure", + description: null, + url: "https://example.com/security-review", + required: true, + }); + }); + + it("reads an expired build as a failure, as Azure counts it", () => { + const [check] = checksFor([ + policy({ + status: "queued", + settings: { displayName: "web-ui" }, + context: { buildId: 3, isExpired: true }, + }), + ]); + + expect([check?.status, check?.description]).toEqual(["failure", "Expired"]); + }); + + it("names the comment policy as Azure's Checks list does", () => { + const [check] = checksFor([ + policy({ + status: "rejected", + typeId: "c6a1889d-b943-4856-b76f-9e46bb6b0df2", + typeName: "Comment requirements", + }), + ]); + + expect([check?.name, check?.status]).toEqual(["Comments must be resolved", "failure"]); + }); + + it("lists the latest of each status no policy waits on as optional", () => { + const statuses = expectSuccess( + decodePullRequestStatusesJson( + JSON.stringify({ + value: [ + { id: 1, state: "pending", description: "Nitpicker", context: { name: "nit" } }, + { + id: 2, + state: "succeeded", + description: "Nitpicker", + targetUrl: "?_a=overview", + context: { name: "nit" }, + }, + { id: 3, state: "notApplicable", description: "Hidden", context: { name: "na" } }, + { id: 5, state: "succeeded", description: "Policy-owned", context: { name: "own" } }, + ], + }), + ), + ); + const checks = checksFor( + [ + policy({ + status: "approved", + typeId: STATUS_TYPE_ID, + typeName: "Status", + context: { latestStatusId: 5 }, + }), + ], + statuses, + ); + + expect(checks).toEqual([ + { name: "Policy-owned", status: "success", description: null, url: null, required: true }, + { name: "Nitpicker", status: "success", description: null, url: null, required: false }, + ]); + }); + + it("reads a manual-only build nobody queued as skipped rather than pending forever", () => { + const checks = checksFor([ + policy({ + status: "queued", + settings: { manualQueueOnly: true, displayName: "Optional perf" }, + }), + policy({ + status: "queued", + settings: { manualQueueOnly: true, displayName: "Queued perf" }, + context: { buildId: 9 }, + }), + policy({ status: "notApplicable", settings: { displayName: "Docs" } }), + policy({ status: "broken", settings: { displayName: "Broken" } }), + ]); + + expect(checks.map((check) => [check.name, check.status, check.description])).toEqual([ + ["Broken", "failure", null], + ["Docs", "skipped", null], + ["Optional perf", "skipped", "Not run"], + ["Queued perf", "pending", null], + ]); + }); +}); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts index b3632ef1af98f..cecb98a603df7 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts @@ -8,6 +8,8 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, PullRequestComment, PullRequestMergeMethod, PullRequestMergeability, @@ -326,6 +328,265 @@ export function decodeThreadsJson( ); } +/** Azure's well-known policy type ids for the policies its Checks list shows. */ +const BUILD_POLICY_TYPE_ID = "0609b952-1397-4640-95ec-e00a01b2c241"; +const STATUS_POLICY_TYPE_ID = "cbdc66da-9728-4af8-aada-9a5a32e4a226"; +const COMMENT_POLICY_TYPE_ID = "c6a1889d-b943-4856-b76f-9e46bb6b0df2"; + +const RawPullRequestStatusSchema = Schema.Struct({ + id: Schema.Number, + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional( + Schema.NullOr( + Schema.Struct({ + genre: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +/** A status an external service posted on a pull request, as Azure's Checks list reads it. */ +export interface AzureDevOpsPullRequestStatus { + readonly id: number; + readonly key: string; + readonly state: string | null; + readonly description: string | null; + readonly url: string | null; +} + +const decodePullRequestStatusEntry = Schema.decodeUnknownExit(RawPullRequestStatusSchema); + +/** Statuses carry relative links such as `?_a=…` for in-page tabs, which lead nowhere here. */ +function absoluteUrl(value: string | null | undefined): string | null { + const url = trimmed(value); + return url !== null && /^https?:\/\//i.test(url) ? url : null; +} + +export function decodePullRequestStatusesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeThreadPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const statuses: AzureDevOpsPullRequestStatus[] = []; + for (const entry of decoded.success.value) { + const decodedEntry = decodePullRequestStatusEntry(entry); + if (Exit.isFailure(decodedEntry)) continue; + const status = decodedEntry.value; + statuses.push({ + id: status.id, + key: `${status.context?.genre?.trim() ?? ""}/${status.context?.name?.trim() ?? ""}`, + state: trimmed(status.state), + description: trimmed(status.description), + url: absoluteUrl(status.targetUrl), + }); + } + return Result.succeed(statuses); +} + +function statusStateToCheckStatus(state: string | null): PullRequestCheckStatus { + switch (state?.toLowerCase()) { + case "succeeded": + return "success"; + case "failed": + case "error": + return "failure"; + case "notapplicable": + return "skipped"; + default: + // Azure shows a status with no state yet, or `pending`, as waiting. + return "pending"; + } +} + +const RawPolicyEvaluationSchema = Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + configuration: Schema.Struct({ + isBlocking: Schema.optional(Schema.NullOr(Schema.Boolean)), + isEnabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + url: Schema.optional(Schema.NullOr(Schema.String)), + type: Schema.optional( + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + displayName: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + settings: Schema.optional( + Schema.NullOr( + Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + defaultDisplayName: Schema.optional(Schema.NullOr(Schema.String)), + statusName: Schema.optional(Schema.NullOr(Schema.String)), + manualQueueOnly: Schema.optional(Schema.NullOr(Schema.Boolean)), + }), + ), + ), + }), + context: Schema.optional( + Schema.NullOr( + Schema.Struct({ + buildDefinitionName: Schema.optional(Schema.NullOr(Schema.String)), + buildId: Schema.optional(Schema.NullOr(Schema.Number)), + isExpired: Schema.optional(Schema.NullOr(Schema.Boolean)), + latestStatusId: Schema.optional(Schema.NullOr(Schema.Number)), + }), + ), + ), +}); + +/** One branch policy's evaluation on a pull request, as `az repos pr policy list` reports it. */ +export type AzureDevOpsPolicyEvaluation = typeof RawPolicyEvaluationSchema.Type; + +const decodePolicyEvaluationEntry = Schema.decodeUnknownExit(RawPolicyEvaluationSchema); + +/** Policies that are switched off or deleted still evaluate, but Azure does not list them. */ +export function decodePolicyEvaluationsJson( + raw: string, +): Result.Result, DecodeFailure> { + // `az` prints nothing at all for a pull request no policy applies to. + const decoded = decodeUnknownList(raw.length === 0 ? "[]" : raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const evaluations: AzureDevOpsPolicyEvaluation[] = []; + for (const entry of decoded.success) { + const decodedEntry = decodePolicyEvaluationEntry(entry); + if (Exit.isFailure(decodedEntry)) continue; + const { configuration } = decodedEntry.value; + if (configuration.isEnabled === false || configuration.isDeleted === true) continue; + evaluations.push(decodedEntry.value); + } + return Result.succeed(evaluations); +} + +function toCheckStatus( + status: string | null | undefined, + neverQueued: boolean, +): PullRequestCheckStatus { + switch (status?.trim().toLowerCase()) { + case "approved": + return "success"; + case "rejected": + case "broken": + return "failure"; + case "running": + return "pending"; + case "queued": + // An optional build that only runs when someone queues it sits at "queued" forever. + return neverQueued ? "skipped" : "pending"; + case "notapplicable": + return "skipped"; + default: + return "neutral"; + } +} + +/** Builds link to their results page, which hangs off the same project as the policy itself. */ +function buildResultsUrl(configurationUrl: string | null, buildId: number | null): string | null { + if (configurationUrl === null || buildId === null) return null; + const apisIndex = configurationUrl.indexOf("/_apis/"); + if (apisIndex < 0) return null; + return `${configurationUrl.slice(0, apisIndex)}/_build/results?buildId=${buildId}`; +} + +/** + * Azure lists build, status and comment policies as checks, but not review policies. + * Statuses no policy waits on are optional; required checks sort first, then by name. + */ +export function toPullRequestChecks( + evaluations: ReadonlyArray, + statuses: ReadonlyArray, +): ReadonlyArray { + const statusesById = new Map(statuses.map((status) => [status.id, status])); + const policyStatusKeys = new Set(); + const checks: PullRequestCheck[] = []; + for (const { status, configuration, context } of evaluations) { + const typeId = configuration.type?.id?.trim().toLowerCase(); + const settings = configuration.settings; + const required = configuration.isBlocking === true; + if (typeId === COMMENT_POLICY_TYPE_ID) { + checks.push({ + name: TrimmedNonEmptyString.make("Comments must be resolved"), + status: toCheckStatus(status, false), + description: null, + url: null, + required, + }); + continue; + } + if (typeId === STATUS_POLICY_TYPE_ID) { + const latestStatus = + context?.latestStatusId == null ? undefined : statusesById.get(context.latestStatusId); + if (latestStatus !== undefined) policyStatusKeys.add(latestStatus.key); + const name = + latestStatus?.description ?? + trimmed(settings?.displayName) ?? + trimmed(settings?.defaultDisplayName) ?? + trimmed(settings?.statusName) ?? + trimmed(configuration.type?.displayName); + if (name === null) continue; + checks.push({ + name: TrimmedNonEmptyString.make(name), + status: + latestStatus === undefined + ? toCheckStatus(status, false) + : statusStateToCheckStatus(latestStatus.state), + description: null, + url: latestStatus?.url ?? null, + required, + }); + continue; + } + if (typeId !== BUILD_POLICY_TYPE_ID) continue; + const name = + trimmed(settings?.displayName) ?? + trimmed(context?.buildDefinitionName) ?? + trimmed(configuration.type?.displayName); + if (name === null) continue; + const buildId = context?.buildId ?? null; + const neverQueued = settings?.manualQueueOnly === true && buildId === null; + // An expired build no longer satisfies its policy, and Azure counts it as failed. + const expired = context?.isExpired === true; + checks.push({ + name: TrimmedNonEmptyString.make(name), + status: expired ? "failure" : toCheckStatus(status, neverQueued), + description: expired ? "Expired" : neverQueued ? "Not run" : null, + url: buildResultsUrl(trimmed(configuration.url), buildId), + required, + }); + } + const latestByKey = new Map(); + for (const status of statuses) { + const latest = latestByKey.get(status.key); + if (latest === undefined || status.id > latest.id) latestByKey.set(status.key, status); + } + for (const [key, status] of latestByKey) { + if (policyStatusKeys.has(key)) continue; + const checkStatus = statusStateToCheckStatus(status.state); + if (checkStatus === "skipped") continue; + const name = status.description ?? trimmed(key.slice(key.indexOf("/") + 1)); + if (name === null) continue; + checks.push({ + name: TrimmedNonEmptyString.make(name), + status: checkStatus, + description: null, + url: status.url, + required: false, + }); + } + return checks.toSorted( + (left, right) => + Number(right.required) - Number(left.required) || left.name.localeCompare(right.name), + ); +} + const RawIterationSchema = Schema.Struct({ id: Schema.Int, sourceRefCommit: Schema.optional(