import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, type ProviderChangeRequest, type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { // `az repos pr` has no diff command, and the REST route reports changed files without their // contents, so there is no patch to show. The Code tab is hidden rather than empty. diff: false, // Reading a conversation is a plain REST read, but posting one is not something this can // claim without having run it, so the composer stays hidden. comment: false, actions: [ "merge", "ready", "draft", "close", "reopen", "enable-auto-merge", "disable-auto-merge", ], // Azure squashes as a completion option; it has no rebase strategy of its own. mergeMethods: ["merge", "squash"], // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. search: false, reactions: false, // With no patch to show there are no lines to write against, so nothing here is offered. review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` // lists the ones this repository could name — that lives behind the identity and graph APIs, a // different service with its own permissions. So the page takes a name here rather than being // handed a menu built out of a guess. reviewers: { request: true, listCandidates: false }, // A new title and description travel on the same `az repos pr update` that moves a pull request. // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on // Azure DevOps at all, so there is nothing here it could rewrite either. edit: { changeRequest: true, comment: false }, }; /** * Everything this host offers, granted to whoever is signed in. 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. * * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment * they try. That is the safer half of an unknown: hiding a control from someone entitled to it * leaves them no way through and no reason given. */ export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, comment: CAPABILITIES.comment, resolve: CAPABILITIES.review.resolve, verdicts: CAPABILITIES.review.verdicts, requestReviewers: CAPABILITIES.reviewers.request, }; /** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ export 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" }; 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 reports no line counts on a pull request, and with no patch to read there is // nothing to count them from either. additions: 0, deletions: 0, createdAt: pullRequest.createdAt, 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 fail = (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => new PullRequestProviderError({ provider: "azure-devops", operation, ...azureDevOpsProviderFailure(error), detail: error.detail, cause: error, }); /** Refuses what the capabilities already say this host cannot do. */ const unsupported = (operation: string) => Effect.fail( new PullRequestProviderError({ provider: "azure-devops", operation, reason: "failed", detail: "Azure DevOps reviews cannot be written from here yet.", }), ); const provider: PullRequestProviderApi = { kind: "azure-devops", capabilities: CAPABILITIES, getViewer: (input) => cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, // reviewer and branch, and has nothing that matches text. Sending it as one of those would // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. 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, }) .pipe( Effect.mapError(fail("listChangeRequests")), Effect.map((batch) => ({ items: batch.items.map(toChangeRequest), truncated: batch.truncated, cursorAdvance: batch.cursorAdvance, // Azure answers in one order whether or not it is being carried on from, so a slice // can always be stepped past — by counting, which is all Azure offers. continues: true, })), ), getChangeRequest: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequest")), Effect.map( (pullRequest): ProviderChangeRequestDetail => ({ ...toChangeRequest(pullRequest), body: pullRequest.body, changedFiles: 0, 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, }), ), ), getChangeRequestActivity: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequestActivity")), Effect.flatMap((pullRequest) => (pullRequest.threadsUrl === null ? Effect.succeed({ comments: [], truncated: true }) : cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).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: [], }), ), ), ), ), // No request at all: Azure has nothing to say about the viewer that a pull request read can // reach, so the answer is the same constant the detail carries. getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), // Never called: `capabilities.diff` is false, and the service refuses a diff without it. getDiff: () => Effect.fail( new PullRequestProviderError({ provider: "azure-devops", operation: "getDiff", reason: "failed", detail: "Azure DevOps cannot produce a patch for a pull request.", }), ), 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"))), // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the // list without it. listReviewerCandidates: () => Effect.fail( new PullRequestProviderError({ provider: "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, // Azure names an identity by an email address or a guid, and has no team to ask, so a // candidate's id is the whole of what it takes. reviewers: input.reviewers.map((reviewer) => reviewer.id), requested: input.requested, }) .pipe(Effect.mapError(fail("setReviewerRequest"))), // Never called: `capabilities.comment` is false, and the service refuses a comment without it. comment: () => unsupported("comment"), // Declared unsupported above, so the service refuses these before a provider is reached. // They exist because every provider answers the whole port. submitReview: () => unsupported("submitReview"), replyToThread: () => unsupported("replyToThread"), setThreadResolution: () => unsupported("setThreadResolution"), setReaction: () => unsupported("setReaction"), }; return provider; });