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(