packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts
import { resolvePullRequestAuthorFilter, SourceControlProviderKind } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Semaphore from "effect/Semaphore";
import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
import {
azureDevOpsFilePatch,
azureDevOpsUnreadableFilePatch,
formatAzureDevOpsDiffCursor,
parseAzureDevOpsDiffCursor,
MAX_DIFF_SLICE_BYTES,
MAX_DIFF_SLICE_EDITS,
MAX_DIFF_SLICE_FILES,
byteLength,
MAX_FILE_DIFF_EDITS,
type AzureDevOpsFileTexts,
} from "./azureDevOpsDiff.ts";
import {
PullRequestProviderError,
type PullRequestProviderFailure,
type ProviderChangeRequest,
type ProviderChangeRequestActivity,
type ProviderChangeRequestDetail,
type ProviderChangeRequestSummary,
type ProviderDiffSlice,
type PullRequestProviderApi,
} from "@t3tools/source-control-core/server/PullRequestProvider";
import type {
AzureDevOpsChangeEntry,
AzureDevOpsItemContent,
AzureDevOpsIteration,
AzureDevOpsPullRequest,
AzureDevOpsRepositoryLocation,
} from "./azureDevOpsPullRequestJson.ts";
import { definition } from "../shared/definition.ts";
/** Each file pays for two Python startups. */
const DIFF_FILE_CONCURRENCY = 4;
/** Readers share a spawn budget across requests. */
export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY;
export const LOCATION_CACHE_CAPACITY = 128;
const CAPABILITIES: PullRequestCapabilities = {
diff: true,
comment: false,
actions: [...definition.changeRequestActions],
// Azure has no rebase strategy.
mergeMethods: ["merge", "squash"],
// `az repos pr list` has no text filter.
search: false,
reactions: false,
review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
// Reviewer candidates require separate identity/graph APIs and permissions.
reviewers: { request: true, listCandidates: false },
edit: { changeRequest: true, comment: false },
// Azure's viewed marks use an undocumented endpoint and are invalidated by each push.
viewedFiles: "environment",
};
const UNAVAILABLE_REQUIREMENT = {
missing:
"Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.",
unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.",
};
/**
* Azure DevOps states no permission anywhere `az repos pr show` or `az repos pr list` reach: the
* answer lives in the security namespaces, behind identity descriptors and token paths that would
* be several calls per pull request to resolve.
*/
const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = {
actions: CAPABILITIES.actions,
comment: CAPABILITIES.comment,
resolve: CAPABILITIES.review.resolve,
verdicts: CAPABILITIES.review.verdicts,
requestReviewers: CAPABILITIES.reviewers.request,
};
function azureDevOpsProviderFailure(
error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError,
): PullRequestProviderFailure {
if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" };
return { reason: "failed" };
}
function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest {
return {
number: pullRequest.number,
title: pullRequest.title,
url: pullRequest.url,
author: pullRequest.author,
headBranch: pullRequest.headBranch,
baseBranch: pullRequest.baseBranch,
state: pullRequest.state,
isDraft: pullRequest.isDraft,
mergeability: pullRequest.mergeability,
// Azure omits line counts; computing them requires both sides of every file.
additions: 0,
deletions: 0,
createdAt: pullRequest.createdAt,
closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
updatedAt: pullRequest.updatedAt,
reviewRequestLogins: pullRequest.reviewRequestLogins,
// Azure keeps labels on work items rather than on the pull request.
labels: [],
};
}
export const make = Effect.gen(function* () {
const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS);
const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) =>
diffSpawns.withPermits(1)(cli.readItemContent(input));
const fail =
(operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
new PullRequestProviderError({
provider: SourceControlProviderKind.make("azure-devops"),
operation,
...azureDevOpsProviderFailure(error),
detail: error.detail,
cause: error,
});
const unsupported = (operation: string) =>
Effect.fail(
new PullRequestProviderError({
provider: SourceControlProviderKind.make("azure-devops"),
operation,
reason: "failed",
detail: "Azure DevOps reviews cannot be written from here yet.",
}),
);
const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null };
// PR locations are immutable; caching avoids full PR reads for viewed-file checks.
const locations = new Map<string, AzureDevOpsRepositoryLocation>();
const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
const key = `${input.cwd} ${input.number}`;
const held = locations.get(key);
if (held !== undefined) {
locations.delete(key);
locations.set(key, held);
return Effect.succeed(held);
}
return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
Effect.map((pullRequest) => {
const location = pullRequest.location;
if (location === null) return null;
if (locations.size >= LOCATION_CACHE_CAPACITY) {
const oldest = locations.keys().next().value;
if (oldest !== undefined) locations.delete(oldest);
}
locations.set(key, location);
return location;
}),
);
};
const diffScope = Effect.fn("AzureDevOpsPullRequestProvider.diffScope")(function* (input: {
readonly cwd: string;
readonly number: number;
}) {
const location = yield* locationOf(input);
if (location === null) return null;
const iterations = yield* cli.listIterations({
cwd: input.cwd,
location,
number: input.number,
});
return { location, iterations };
});
const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false };
/** Azure fails reads of absent files. */
const readTexts = (input: {
readonly cwd: string;
readonly location: AzureDevOpsRepositoryLocation;
readonly iteration: AzureDevOpsIteration;
readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">;
}) =>
Effect.all(
[
input.change.changeKind === "new"
? Effect.succeed(EMPTY_ITEM)
: readItemContent({
cwd: input.cwd,
location: input.location,
path: input.change.oldPath,
commit: input.iteration.mergeBaseCommit,
}),
input.change.changeKind === "deleted"
? Effect.succeed(EMPTY_ITEM)
: readItemContent({
cwd: input.cwd,
location: input.location,
path: input.change.path,
commit: input.iteration.headCommit,
}),
],
{ concurrency: 2 },
).pipe(
Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({
oldContents: oldItem.contents,
newContents: newItem.contents,
// Encoded bytes cannot identify binaries; trust Azure's metadata.
binary: oldItem.isBinary || newItem.isBinary,
})),
);
const listLatestChanges = (input: {
readonly cwd: string;
readonly location: AzureDevOpsRepositoryLocation;
readonly number: number;
readonly iterations: ReadonlyArray<AzureDevOpsIteration>;
}) => {
const latest = input.iterations.at(-1);
return latest === undefined
? Effect.succeed({
changes: [],
truncated: false,
} as AzureDevOpsPullRequestCli.AzureDevOpsIterationChanges)
: cli.listIterationChanges({
cwd: input.cwd,
location: input.location,
number: input.number,
iterationId: latest.id,
});
};
const provider: PullRequestProviderApi = {
kind: SourceControlProviderKind.make("azure-devops"),
capabilities: CAPABILITIES,
unavailableRequirement: UNAVAILABLE_REQUIREMENT,
// The bare repository name repeats across an organization; the project path does not.
repositoryKey: ({ canonicalKey }) => canonicalKey,
getViewer: (input) =>
cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
listChangeRequests: (input) =>
cli
.listPullRequests({
cwd: input.cwd,
repository: input.repository,
state: input.state,
involvement: input.involvement,
viewer: input.viewer,
limit: input.limit,
cursor: input.cursor,
author:
input.filters?.author === undefined
? undefined
: resolvePullRequestAuthorFilter(input.filters.author, input.viewer),
})
.pipe(
Effect.mapError(fail("listChangeRequests")),
Effect.map((batch) => ({
items: batch.items.map(toChangeRequest),
truncated: batch.truncated,
cursorAdvance: batch.cursorAdvance,
// Azure's ordering is stable across offset-based pages.
continues: true,
})),
),
getChangeRequestPreview: (input) =>
cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
Effect.mapError(fail("getChangeRequestPreview")),
Effect.map((pullRequest) => ({
number: pullRequest.number,
title: pullRequest.title,
url: pullRequest.url,
author: pullRequest.author,
state: pullRequest.state,
isDraft: pullRequest.isDraft,
createdAt: pullRequest.createdAt,
})),
),
getChangeRequestSummary: (input) =>
cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
Effect.mapError(fail("getChangeRequestSummary")),
Effect.map((pullRequest): ProviderChangeRequestSummary => ({
number: pullRequest.number,
title: pullRequest.title,
url: pullRequest.url,
author: pullRequest.author,
headBranch: pullRequest.headBranch,
baseBranch: pullRequest.baseBranch,
state: pullRequest.state,
isDraft: pullRequest.isDraft,
mergeability: pullRequest.mergeability,
closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
updatedAt: pullRequest.updatedAt,
})),
),
getChangeRequest: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequest")(
function* (input) {
const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
const location = pullRequest.location;
const iterations =
location === null
? []
: yield* cli
.listIterations({ cwd: input.cwd, location, number: input.number })
.pipe(Effect.orElseSucceed(() => []));
// The trial merge's lastMergeSourceCommit can lag behind a new source push.
const headSha = iterations.at(-1)?.headCommit;
const changedFiles =
location === null
? 0
: yield* listLatestChanges({
cwd: input.cwd,
location,
number: input.number,
iterations,
}).pipe(
Effect.map((listed) => listed.changes.length),
Effect.orElseSucceed(() => 0),
);
const detail: ProviderChangeRequestDetail = {
...toChangeRequest(pullRequest),
body: pullRequest.body,
...(headSha === undefined ? {} : { headSha }),
changedFiles,
mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
reviewers: pullRequest.reviewers,
checks: [],
mergeCapabilities: { merge: true, squash: true, rebase: false },
viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
autoMergeEnabled: pullRequest.autoMergeEnabled,
...(pullRequest.autoMergeMethod === undefined
? {}
: { autoMergeMethod: pullRequest.autoMergeMethod }),
};
return detail;
},
Effect.mapError(fail("getChangeRequest")),
),
getChangeRequestActivity: (input) =>
cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
Effect.mapError(fail("getChangeRequestActivity")),
Effect.flatMap((pullRequest) =>
(pullRequest.location === null
? Effect.succeed({ comments: [], truncated: true })
: cli
.listThreads({
cwd: input.cwd,
location: pullRequest.location,
number: input.number,
})
.pipe(
Effect.map((comments) => ({ comments, truncated: false })),
Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
)
).pipe(
Effect.map((conversation): ProviderChangeRequestActivity => ({
comments: conversation.comments,
commentCount: conversation.comments.length,
commentsTruncated: conversation.truncated,
reviewThreads: [],
commits: [],
})),
),
),
),
getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS),
// `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so
// the Code tab has nothing to scope itself to and always asks for the whole change.
getDiff: Effect.fn("AzureDevOpsPullRequestProvider.getDiff")(
function* (input) {
const scope = yield* diffScope(input);
if (scope === null) return EMPTY_DIFF_SLICE;
const cursor = parseAzureDevOpsDiffCursor(input.cursor);
const iteration =
cursor === null
? scope.iterations.at(-1)
: scope.iterations.find((candidate) => candidate.id === cursor.iterationId);
if (iteration === undefined) return EMPTY_DIFF_SLICE;
const listed = yield* cli.listIterationChanges({
cwd: input.cwd,
location: scope.location,
number: input.number,
iterationId: iteration.id,
});
const changes = listed.changes;
const sections: string[] = [];
let truncated = listed.truncated;
let bytes = 0;
let edits = 0;
let index = cursor?.fileIndex ?? 0;
let full = false;
// Prefetched files beyond the slice must be read again.
const batchWidth = () => {
if (sections.length === 0) return DIFF_FILE_CONCURRENCY;
const admits = (left: number, spent: number) =>
Math.ceil(left / Math.max(1, spent / sections.length));
return Math.max(
1,
Math.min(
DIFF_FILE_CONCURRENCY,
MAX_DIFF_SLICE_FILES - sections.length,
admits(MAX_DIFF_SLICE_BYTES - bytes, bytes),
admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits),
),
);
};
while (!full && index < changes.length) {
const batch = changes.slice(index, index + batchWidth());
const read = yield* Effect.forEach(
batch,
(change) =>
readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe(
// Auth, rate-limit and CLI failures must reach the caller.
Effect.catchTags({
AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null),
AzureDevOpsCommandFailedError: () => Effect.succeed(null),
AzureDevOpsPullRequestReadError: () => Effect.succeed(null),
}),
Effect.map((texts) => ({ change, texts })),
),
{ concurrency: DIFF_FILE_CONCURRENCY },
);
for (const { change, texts } of read) {
// Synchronous diffs need yields to let other server work run.
yield* Effect.yieldNow;
const file =
texts === null
? azureDevOpsUnreadableFilePatch(change)
: azureDevOpsFilePatch({ change, texts });
sections.push(file.section);
bytes += byteLength(file.section);
edits += file.edits;
truncated = truncated || file.truncated;
index += 1;
// Add a file before checking budgets so oversized files cannot stall the cursor.
if (
bytes >= MAX_DIFF_SLICE_BYTES ||
edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS ||
sections.length >= MAX_DIFF_SLICE_FILES ||
file.abandoned
) {
full = true;
break;
}
}
}
const slice: ProviderDiffSlice = {
patch: sections.join(""),
truncated,
nextCursor:
index >= changes.length
? null
: formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }),
};
return slice;
},
Effect.mapError(fail("getDiff")),
),
getDiffFileContents: Effect.fn("AzureDevOpsPullRequestProvider.getDiffFileContents")(
function* (input) {
const scope = yield* diffScope(input);
const iteration = scope?.iterations.at(-1);
if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" };
return yield* readTexts({
cwd: input.cwd,
location: scope.location,
iteration,
change: {
changeKind: input.changeType,
path: input.newPath,
oldPath: input.oldPath,
},
});
},
Effect.mapError(fail("getDiffFileContents")),
),
/**
* A path the change does not carry is at the empty revision, which is where a file the pull
* request deletes sits. When the change was too long to follow to its end, those paths are left
* out instead: reporting them as deleted would clear a file nobody has read.
*/
getFileRevisions: Effect.fn("AzureDevOpsPullRequestProvider.getFileRevisions")(
function* (input) {
const revisions = new Map<string, string>();
if (input.paths.length === 0) return { revisions };
const scope = yield* diffScope(input);
if (scope === null) return { revisions };
const listed = yield* listLatestChanges({
...scope,
cwd: input.cwd,
number: input.number,
});
const marked = new Set(input.paths);
for (const change of listed.changes) {
if (!marked.has(change.path) || change.objectId === null) continue;
revisions.set(change.path, change.objectId);
}
if (!listed.truncated) {
for (const path of input.paths) {
if (!revisions.has(path)) revisions.set(path, "");
}
}
return { revisions };
},
Effect.mapError(fail("getFileRevisions")),
),
runAction: (input) =>
cli
.runPullRequestAction({
cwd: input.cwd,
number: input.number,
action: input.action,
...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
})
.pipe(Effect.mapError(fail("runAction"))),
updateChangeRequest: (input) =>
cli
.updatePullRequest({
cwd: input.cwd,
number: input.number,
title: input.title,
body: input.body,
})
.pipe(Effect.mapError(fail("updateChangeRequest"))),
listReviewerCandidates: () =>
Effect.fail(
new PullRequestProviderError({
provider: SourceControlProviderKind.make("azure-devops"),
operation: "listReviewerCandidates",
reason: "failed",
detail: "Azure DevOps cannot say who may review a pull request.",
}),
),
setReviewerRequest: (input) =>
cli
.setPullRequestReviewers({
cwd: input.cwd,
number: input.number,
reviewers: input.reviewers.map((reviewer) => reviewer.id),
requested: input.requested,
})
.pipe(Effect.mapError(fail("setReviewerRequest"))),
comment: () => unsupported("comment"),
submitReview: () => unsupported("submitReview"),
replyToThread: () => unsupported("replyToThread"),
setThreadResolution: () => unsupported("setThreadResolution"),
setReaction: () => unsupported("setReaction"),
};
return provider;
});