Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
}),
);

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -157,6 +164,22 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
readonly number: number;
}) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>;

/** 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<ReadonlyArray<AzureDevOpsPolicyEvaluation>, 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<AzureDevOpsPullRequestStatus>,
AzureDevOpsPullRequestCliError
>;

readonly listThreads: (input: {
readonly cwd: string;
readonly location: AzureDevOpsRepositoryLocation;
Expand Down Expand Up @@ -299,6 +322,8 @@ export const make = Effect.gen(function* () {
readonly routeParameters: ReadonlyArray<string>;
readonly queryParameters?: ReadonlyArray<string>;
readonly maxOutputBytes?: number;
/** A JMESPath projection `az` applies before printing, to keep large responses small. */
readonly jmesQuery?: string;
readonly decode: (raw: string) => Result.Result<A, unknown>;
}): Effect.Effect<A, AzureDevOpsPullRequestCliError> =>
executeJson({
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand All @@ -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",
Expand Down Expand Up @@ -140,6 +162,8 @@ describe("getChangeRequest", () => {
cause: "unavailable",
}),
),
listPolicyEvaluations: () => Effect.succeed([]),
listPullRequestStatuses: () => Effect.succeed([]),
}),
),
);
Expand Down Expand Up @@ -174,6 +198,8 @@ describe("getChangeRequest", () => {
cause: "unavailable",
}),
),
listPolicyEvaluations: () => Effect.succeed([]),
listPullRequestStatuses: () => Effect.succeed([]),
}),
),
);
Expand All @@ -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<typeof make>) => {
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", () => {
Expand Down
Loading
Loading