import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type { PullRequestAction, PullRequestComment, PullRequestInvolvement, PullRequestListState, PullRequestMergeMethod,} from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";import { decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, decodeViewerJson, type AzureDevOpsPullRequest,} from "./azureDevOpsPullRequestJson.ts";import type { ProviderListCursor } from "./PullRequestProvider.ts"; /** * Names the read that produced unusable output, so a failure reports the call it came from * rather than borrowing another operation's message. */export class AzureDevOpsPullRequestReadError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestReadError>()( "AzureDevOpsPullRequestReadError", { command: Schema.Literal("az"), cwd: Schema.String, operation: Schema.String, cause: Schema.Defect(), },) { get detail(): string { return `Azure CLI returned an unreadable ${this.operation} response.`; } override get message(): string { return `Azure CLI failed in ${this.operation}: ${this.detail}`; }} /** Not a decode failure: az answered, the account it answered for just has no name. */export class AzureDevOpsViewerUnavailableError extends Schema.TaggedErrorClass<AzureDevOpsViewerUnavailableError>()( "AzureDevOpsViewerUnavailableError", { command: Schema.Literal("az"), cwd: Schema.String, },) { get detail(): string { return "Azure CLI returned no account for the current sign-in."; } override get message(): string { return `Azure CLI failed in getViewer: ${this.detail}`; }} /** * Not a decode failure either: az answered with a well-formed pull request that simply carries * no branch or link, which is a response this cannot place rather than one it cannot read. */export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestIncompleteError>()( "AzureDevOpsPullRequestIncompleteError", { command: Schema.Literal("az"), cwd: Schema.String, number: Schema.Int, },) { get detail(): string { return "Azure DevOps returned no branch or link for the pull request."; } override get message(): string { return `Azure CLI failed in getPullRequest: ${this.detail}`; }} /** * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no * other way — so anything that could leave the value position is refused rather than sent. */export class AzureDevOpsReviewerNameError extends Schema.TaggedErrorClass<AzureDevOpsReviewerNameError>()( "AzureDevOpsReviewerNameError", { command: Schema.Literal("az"), cwd: Schema.String, },) { get detail(): string { return "A reviewer is named by an email address or an identity id."; } override get message(): string { return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; }} export type AzureDevOpsPullRequestCliError = | AzureDevOpsCli.AzureDevOpsCliError | AzureDevOpsPullRequestReadError | AzureDevOpsPullRequestIncompleteError | AzureDevOpsReviewerNameError | AzureDevOpsViewerUnavailableError; /** The version every REST call below is pinned to, so a new default cannot reshape a response. */const REST_API_VERSION = "7.1"; export class AzureDevOpsPullRequestCli extends Context.Service< AzureDevOpsPullRequestCli, { readonly getViewer: (input: { readonly cwd: string; }) => Effect.Effect<string, AzureDevOpsPullRequestCliError>; readonly listPullRequests: (input: { readonly cwd: string; readonly repository: string; readonly state: PullRequestListState; readonly involvement: PullRequestInvolvement; readonly viewer: string; readonly limit: number; /** * Where to carry on from. Azure has no date filter for a pull request listing, so the only * part of a cursor it can use is how many rows have already been handed over. */ readonly cursor?: ProviderListCursor | undefined; }) => Effect.Effect< { readonly items: ReadonlyArray<AzureDevOpsPullRequest>; readonly truncated: boolean; /** Raw Azure rows consumed to produce this page, including malformed rows. */ readonly cursorAdvance: number; }, AzureDevOpsPullRequestCliError >; readonly getPullRequest: (input: { readonly cwd: string; readonly number: number; }) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>; /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ readonly listThreads: (input: { readonly cwd: string; readonly threadsUrl: string; }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>; readonly runPullRequestAction: (input: { readonly cwd: string; readonly number: number; readonly action: PullRequestAction; readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; /** * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole * of what Azure offers here: it adds and removes named identities, and has no counterpart that * says who could be named. */ readonly setPullRequestReviewers: (input: { readonly cwd: string; readonly number: number; readonly reviewers: ReadonlyArray<string>; readonly requested: boolean; }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>; }>()("t3/pullRequest/AzureDevOpsPullRequestCli") {} function statusArgs(state: PullRequestListState): ReadonlyArray<string> { switch (state) { case "open": return ["--status", "active"]; case "merged": return ["--status", "completed"]; case "closed": return ["--status", "abandoned"]; case "all": return ["--status", "all"]; }} function involvementArgs(input: { readonly involvement: PullRequestInvolvement; readonly viewer: string;}): ReadonlyArray<string> { switch (input.involvement) { case "authored": return ["--creator", input.viewer]; case "reviewing": return ["--reviewer", input.viewer]; case "all": return []; }} /** * Azure moves a pull request by setting its state rather than by named commands: completing it * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a * completion option rather than a strategy of its own. */function actionArgs( action: PullRequestAction, mergeMethod: PullRequestMergeMethod | undefined,): ReadonlyArray<string> { switch (action) { case "merge": return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; case "ready": return ["--draft", "false"]; case "draft": return ["--draft", "true"]; case "close": return ["--status", "abandoned"]; case "reopen": return ["--status", "active"]; }} /** * A reviewer Azure could be given: an email address, a display name or an identity guid, and * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that * looks like a flag stops being a value. */function isReviewerName(value: string): boolean { const name = value.trim(); return name.length > 0 && !name.startsWith("-");} export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; // Every command resolves the organization, project and repository from the checkout, which is // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows // how to read all of them. const detectArgs = ["--detect", "true"] as const; const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray<string> }) => azure.execute({ cwd: input.cwd, args: [...input.args, "--only-show-errors", "--output", "json"], }); /** * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and * retain the raw count so the next public cursor skips every row this walk consumed. */ const listPullRequestPage = (input: { readonly cwd: string; readonly repository: string; readonly state: PullRequestListState; readonly involvement: PullRequestInvolvement; readonly viewer: string; readonly limit: number; readonly skip: number; readonly cursorAdvance: number; readonly items: ReadonlyArray<AzureDevOpsPullRequest>; }): Effect.Effect< { readonly items: ReadonlyArray<AzureDevOpsPullRequest>; readonly truncated: boolean; readonly cursorAdvance: number; }, AzureDevOpsPullRequestCliError > => { const remaining = input.limit - input.items.length; const top = remaining + 1; return executeJson({ cwd: input.cwd, args: [ "repos", "pr", "list", ...detectArgs, "--repository", input.repository, ...statusArgs(input.state), ...involvementArgs(input), // A web link per row, which is the only url that needs no assembling. "--include-links", ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), "--top", String(top), ], }).pipe( Effect.flatMap((result) => { const raw = result.stdout.trim(); if (raw.length === 0) { return Effect.succeed({ items: input.items, truncated: false, cursorAdvance: input.cursorAdvance, }); } const decoded = decodePullRequestListJson(raw); if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsPullRequestReadError({ command: "az", cwd: input.cwd, operation: "listPullRequests", cause: decoded.failure, }), ); } const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; if (lastItemIndex !== undefined) { const consumed = lastItemIndex + 1; return Effect.succeed({ items: [...input.items, ...decoded.success.items.slice(0, remaining)], // A full raw response may have more rows even when malformed entries used the probe. truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, cursorAdvance: input.cursorAdvance + consumed, }); } const items = [...input.items, ...decoded.success.items]; if (decoded.success.rawCount < top) { return Effect.succeed({ items, truncated: false, cursorAdvance: input.cursorAdvance + decoded.success.rawCount, }); } return listPullRequestPage({ ...input, skip: input.skip + decoded.success.rawCount, cursorAdvance: input.cursorAdvance + decoded.success.rawCount, items, }); }), ); }; return AzureDevOpsPullRequestCli.of({ getViewer: (input) => executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => { // `--query user` narrows the payload to the account, so it is nested back under the // key the decoder reads to keep one shape for the signed-in user. const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsPullRequestReadError({ command: "az", cwd: input.cwd, operation: "getViewer", cause: decoded.failure, }), ); } return decoded.success === null ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) : Effect.succeed(decoded.success); }), ), listPullRequests: (input) => listPullRequestPage({ cwd: input.cwd, repository: input.repository, state: input.state, involvement: input.involvement, viewer: input.viewer, limit: input.limit, // Azure counts rather than filters, so a slice carries on by stepping over every raw row // the prior slice consumed. That is an offset into a list that can shift underneath it: // a pull request opened between two slices moves everything down one, and the row on the // seam is the one that pays for it. skip: input.cursor?.delivered ?? 0, cursorAdvance: 0, items: [], }), getPullRequest: (input) => executeJson({ cwd: input.cwd, args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], }).pipe( Effect.flatMap( (result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => { const decoded = decodePullRequestJson(result.stdout.trim()); if (!Result.isSuccess(decoded)) { return Effect.fail( new AzureDevOpsPullRequestReadError({ command: "az", cwd: input.cwd, operation: "getPullRequest", cause: decoded.failure, }), ); } // Null means Azure answered with too little to place the pull request. Nothing // failed underneath it, so it is its own outcome rather than a decode failure. return decoded.success === null ? Effect.fail( new AzureDevOpsPullRequestIncompleteError({ command: "az", cwd: input.cwd, number: input.number, }), ) : Effect.succeed(decoded.success); }, ), ), listThreads: (input) => executeJson({ cwd: input.cwd, args: [ "rest", "--method", "get", "--url", `${input.threadsUrl}?api-version=${REST_API_VERSION}`, ], }).pipe( Effect.flatMap((result) => { const decoded = decodeThreadsJson(result.stdout.trim()); return Result.isSuccess(decoded) ? Effect.succeed(decoded.success) : Effect.fail( new AzureDevOpsPullRequestReadError({ command: "az", cwd: input.cwd, operation: "listThreads", cause: decoded.failure, }), ); }), ), setPullRequestReviewers: (input) => input.reviewers.some((reviewer) => !isReviewerName(reviewer)) ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) : azure .execute({ cwd: input.cwd, args: [ "repos", "pr", "reviewer", input.requested ? "add" : "remove", ...detectArgs, "--id", String(input.number), // One `--reviewers` takes them all, because az reads the flag as a list and a // second one would replace the first rather than add to it. "--reviewers", ...input.reviewers, "--only-show-errors", "--output", "json", ], }) .pipe(Effect.asVoid), runPullRequestAction: (input) => azure .execute({ cwd: input.cwd, args: [ "repos", "pr", "update", ...detectArgs, "--id", String(input.number), ...actionArgs(input.action, input.mergeMethod), "--only-show-errors", "--output", "json", ], }) .pipe(Effect.asVoid), });}); export const layer = Layer.effect(AzureDevOpsPullRequestCli, make);
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import type {
PullRequestAction,
PullRequestComment,
PullRequestInvolvement,
PullRequestListState,
PullRequestMergeMethod,
} from "@t3tools/contracts";
import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
import {
decodePullRequestJson,
decodePullRequestListJson,
decodeThreadsJson,
decodeViewerJson,
type AzureDevOpsPullRequest,
} from "./azureDevOpsPullRequestJson.ts";
import type { ProviderListCursor } from "./PullRequestProvider.ts";
/**
* Names the read that produced unusable output, so a failure reports the call it came from
* rather than borrowing another operation's message.
*/
export class AzureDevOpsPullRequestReadError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestReadError>()(
"AzureDevOpsPullRequestReadError",
{
command: Schema.Literal("az"),
cwd: Schema.String,
operation: Schema.String,
cause: Schema.Defect(),
},
) {
get detail(): string {
return `Azure CLI returned an unreadable ${this.operation} response.`;
}
override get message(): string {
return `Azure CLI failed in ${this.operation}: ${this.detail}`;
}
}
/** Not a decode failure: az answered, the account it answered for just has no name. */
export class AzureDevOpsViewerUnavailableError extends Schema.TaggedErrorClass<AzureDevOpsViewerUnavailableError>()(
"AzureDevOpsViewerUnavailableError",
{
command: Schema.Literal("az"),
cwd: Schema.String,
},
) {
get detail(): string {
return "Azure CLI returned no account for the current sign-in.";
}
override get message(): string {
return `Azure CLI failed in getViewer: ${this.detail}`;
}
}
/**
* Not a decode failure either: az answered with a well-formed pull request that simply carries
* no branch or link, which is a response this cannot place rather than one it cannot read.
*/
export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestIncompleteError>()(
"AzureDevOpsPullRequestIncompleteError",
{
command: Schema.Literal("az"),
cwd: Schema.String,
number: Schema.Int,
},
) {
get detail(): string {
return "Azure DevOps returned no branch or link for the pull request.";
}
override get message(): string {
return `Azure CLI failed in getPullRequest: ${this.detail}`;
}
}
/**
* Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The
* reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no
* other way — so anything that could leave the value position is refused rather than sent.
*/
export class AzureDevOpsReviewerNameError extends Schema.TaggedErrorClass<AzureDevOpsReviewerNameError>()(
"AzureDevOpsReviewerNameError",
{
command: Schema.Literal("az"),
cwd: Schema.String,
},
) {
get detail(): string {
return "A reviewer is named by an email address or an identity id.";
}
override get message(): string {
return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`;
}
}
export type AzureDevOpsPullRequestCliError =
| AzureDevOpsCli.AzureDevOpsCliError
| AzureDevOpsPullRequestReadError
| AzureDevOpsPullRequestIncompleteError
| AzureDevOpsReviewerNameError
| AzureDevOpsViewerUnavailableError;
/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
const REST_API_VERSION = "7.1";
export class AzureDevOpsPullRequestCli extends Context.Service<
AzureDevOpsPullRequestCli,
{
readonly getViewer: (input: {
readonly cwd: string;
}) => Effect.Effect<string, AzureDevOpsPullRequestCliError>;
readonly listPullRequests: (input: {
readonly cwd: string;
readonly repository: string;
readonly state: PullRequestListState;
readonly involvement: PullRequestInvolvement;
readonly viewer: string;
readonly limit: number;
/**
* Where to carry on from. Azure has no date filter for a pull request listing, so the only
* part of a cursor it can use is how many rows have already been handed over.
*/
readonly cursor?: ProviderListCursor | undefined;
}) => Effect.Effect<
{
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
readonly truncated: boolean;
/** Raw Azure rows consumed to produce this page, including malformed rows. */
readonly cursorAdvance: number;
},
AzureDevOpsPullRequestCliError
>;
readonly getPullRequest: (input: {
readonly cwd: string;
readonly number: number;
}) => Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError>;
/** Threads are not reachable through `az repos pr`, so they come from the REST API. */
readonly listThreads: (input: {
readonly cwd: string;
readonly threadsUrl: string;
}) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;
readonly runPullRequestAction: (input: {
readonly cwd: string;
readonly number: number;
readonly action: PullRequestAction;
readonly mergeMethod?: PullRequestMergeMethod;
}) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
/**
* Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole
* of what Azure offers here: it adds and removes named identities, and has no counterpart that
* says who could be named.
*/
readonly setPullRequestReviewers: (input: {
readonly cwd: string;
readonly number: number;
readonly reviewers: ReadonlyArray<string>;
readonly requested: boolean;
}) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
}
>()("t3/pullRequest/AzureDevOpsPullRequestCli") {}
function statusArgs(state: PullRequestListState): ReadonlyArray<string> {
switch (state) {
case "open":
return ["--status", "active"];
case "merged":
return ["--status", "completed"];
case "closed":
return ["--status", "abandoned"];
case "all":
return ["--status", "all"];
}
}
function involvementArgs(input: {
readonly involvement: PullRequestInvolvement;
readonly viewer: string;
}): ReadonlyArray<string> {
switch (input.involvement) {
case "authored":
return ["--creator", input.viewer];
case "reviewing":
return ["--reviewer", input.viewer];
case "all":
return [];
}
}
/**
* Azure moves a pull request by setting its state rather than by named commands: completing it
* is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a
* completion option rather than a strategy of its own.
*/
function actionArgs(
action: PullRequestAction,
mergeMethod: PullRequestMergeMethod | undefined,
): ReadonlyArray<string> {
switch (action) {
case "merge":
return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"];
case "ready":
return ["--draft", "false"];
case "draft":
return ["--draft", "true"];
case "close":
return ["--status", "abandoned"];
case "reopen":
return ["--status", "active"];
}
}
/**
* A reviewer Azure could be given: an email address, a display name or an identity guid, and
* nothing that starts with a dash. The dash is the whole point — these are argv, and a value that
* looks like a flag stops being a value.
*/
function isReviewerName(value: string): boolean {
const name = value.trim();
return name.length > 0 && !name.startsWith("-");
}
export const make = Effect.gen(function* () {
const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
// Every command resolves the organization, project and repository from the checkout, which is
// what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows
// how to read all of them.
const detectArgs = ["--detect", "true"] as const;
const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray<string> }) =>
azure.execute({
cwd: input.cwd,
args: [...input.args, "--only-show-errors", "--output", "json"],
});
/**
* Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and
* retain the raw count so the next public cursor skips every row this walk consumed.
*/
const listPullRequestPage = (input: {
readonly cwd: string;
readonly repository: string;
readonly state: PullRequestListState;
readonly involvement: PullRequestInvolvement;
readonly viewer: string;
readonly limit: number;
readonly skip: number;
readonly cursorAdvance: number;
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
}): Effect.Effect<
{
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
readonly truncated: boolean;
readonly cursorAdvance: number;
},
AzureDevOpsPullRequestCliError
> => {
const remaining = input.limit - input.items.length;
const top = remaining + 1;
return executeJson({
cwd: input.cwd,
args: [
"repos",
"pr",
"list",
...detectArgs,
"--repository",
input.repository,
...statusArgs(input.state),
...involvementArgs(input),
// A web link per row, which is the only url that needs no assembling.
"--include-links",
...(input.skip === 0 ? [] : ["--skip", String(input.skip)]),
"--top",
String(top),
],
}).pipe(
Effect.flatMap((result) => {
const raw = result.stdout.trim();
if (raw.length === 0) {
return Effect.succeed({
items: input.items,
truncated: false,
cursorAdvance: input.cursorAdvance,
});
}
const decoded = decodePullRequestListJson(raw);
if (!Result.isSuccess(decoded)) {
return Effect.fail(
new AzureDevOpsPullRequestReadError({
command: "az",
cwd: input.cwd,
operation: "listPullRequests",
cause: decoded.failure,
}),
);
}
const lastItemIndex = decoded.success.rawIndexes[remaining - 1];
if (lastItemIndex !== undefined) {
const consumed = lastItemIndex + 1;
return Effect.succeed({
items: [...input.items, ...decoded.success.items.slice(0, remaining)],
// A full raw response may have more rows even when malformed entries used the probe.
truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top,
cursorAdvance: input.cursorAdvance + consumed,
});
}
const items = [...input.items, ...decoded.success.items];
if (decoded.success.rawCount < top) {
return Effect.succeed({
items,
truncated: false,
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
});
}
return listPullRequestPage({
...input,
skip: input.skip + decoded.success.rawCount,
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
items,
});
}),
);
};
return AzureDevOpsPullRequestCli.of({
getViewer: (input) =>
executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe(
Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
// `--query user` narrows the payload to the account, so it is nested back under the
// key the decoder reads to keep one shape for the signed-in user.
const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`);
if (!Result.isSuccess(decoded)) {
return Effect.fail(
new AzureDevOpsPullRequestReadError({
command: "az",
cwd: input.cwd,
operation: "getViewer",
cause: decoded.failure,
}),
);
}
return decoded.success === null
? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))
: Effect.succeed(decoded.success);
}),
),
listPullRequests: (input) =>
listPullRequestPage({
cwd: input.cwd,
repository: input.repository,
state: input.state,
involvement: input.involvement,
viewer: input.viewer,
limit: input.limit,
// Azure counts rather than filters, so a slice carries on by stepping over every raw row
// the prior slice consumed. That is an offset into a list that can shift underneath it:
// a pull request opened between two slices moves everything down one, and the row on the
// seam is the one that pays for it.
skip: input.cursor?.delivered ?? 0,
cursorAdvance: 0,
items: [],
}),
getPullRequest: (input) =>
executeJson({
cwd: input.cwd,
args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)],
}).pipe(
Effect.flatMap(
(result): Effect.Effect<AzureDevOpsPullRequest, AzureDevOpsPullRequestCliError> => {
const decoded = decodePullRequestJson(result.stdout.trim());
if (!Result.isSuccess(decoded)) {
return Effect.fail(
new AzureDevOpsPullRequestReadError({
command: "az",
cwd: input.cwd,
operation: "getPullRequest",
cause: decoded.failure,
}),
);
}
// Null means Azure answered with too little to place the pull request. Nothing
// failed underneath it, so it is its own outcome rather than a decode failure.
return decoded.success === null
? Effect.fail(
new AzureDevOpsPullRequestIncompleteError({
command: "az",
cwd: input.cwd,
number: input.number,
}),
)
: Effect.succeed(decoded.success);
},
),
),
listThreads: (input) =>
executeJson({
cwd: input.cwd,
args: [
"rest",
"--method",
"get",
"--url",
`${input.threadsUrl}?api-version=${REST_API_VERSION}`,
],
}).pipe(
Effect.flatMap((result) => {
const decoded = decodeThreadsJson(result.stdout.trim());
return Result.isSuccess(decoded)
? Effect.succeed(decoded.success)
: Effect.fail(
new AzureDevOpsPullRequestReadError({
command: "az",
cwd: input.cwd,
operation: "listThreads",
cause: decoded.failure,
}),
);
}),
),
setPullRequestReviewers: (input) =>
input.reviewers.some((reviewer) => !isReviewerName(reviewer))
? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd }))
: azure
.execute({
cwd: input.cwd,
args: [
"repos",
"pr",
"reviewer",
input.requested ? "add" : "remove",
...detectArgs,
"--id",
String(input.number),
// One `--reviewers` takes them all, because az reads the flag as a list and a
// second one would replace the first rather than add to it.
"--reviewers",
...input.reviewers,
"--only-show-errors",
"--output",
"json",
],
})
.pipe(Effect.asVoid),
runPullRequestAction: (input) =>
azure
.execute({
cwd: input.cwd,
args: [
"repos",
"pr",
"update",
...detectArgs,
"--id",
String(input.number),
...actionArgs(input.action, input.mergeMethod),
"--only-show-errors",
"--output",
"json",
],
})
.pipe(Effect.asVoid),
});
});
export const layer = Layer.effect(AzureDevOpsPullRequestCli, make);