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, PullRequestCheck, PullRequestComment, PullRequestCommit, PullRequestListState, PullRequestMergeMethod, PullRequestMergeability, PullRequestReviewCommentDraft, PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList,} from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";import { buildReviewThreads, decodeCommentsJson, decodeCommitsJson, decodeConflictsJson, decodeDiffstatJson, decodePullRequestJson, decodePullRequestPageJson, decodeRepositoryPermissionJson, decodeStatusesJson, decodeViewerJson, decodeWorkspaceMembersJson, type BitbucketDiffStat, type BitbucketPullRequest, type BitbucketRawComment,} from "./bitbucketPullRequestJson.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 BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()( "BitbucketPullRequestReadError", { operation: Schema.String, cause: Schema.Defect(), },) { get detail(): string { return `Bitbucket returned an unreadable ${this.operation} response.`; } override get message(): string { return `Bitbucket failed in ${this.operation}: ${this.detail}`; }} /** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()( "BitbucketViewerUnavailableError", {},) { get detail(): string { return "Bitbucket returned no account name for the configured credentials."; } override get message(): string { return `Bitbucket failed in getViewer: ${this.detail}`; }} /** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()( "BitbucketRepositoryUnsupportedError", { repository: Schema.String, },) { get detail(): string { return "A Bitbucket repository is addressed as workspace/repository."; } override get message(): string { return `Bitbucket failed in resolveRepository: ${this.detail}`; }} /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()( "BitbucketDiffCommitError", {},) { get detail(): string { return "The named commit was not a commit sha."; } override get message(): string { return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; }} export type BitbucketPullRequestApiError = | BitbucketApi.BitbucketApiError | BitbucketPullRequestReadError | BitbucketViewerUnavailableError | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; /** * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a * permission — Bitbucket sends this for every account now, not only ones it would have refused. */function isRepositoryPermissionRemovedError( error: BitbucketPullRequestApiError,): error is BitbucketApi.BitbucketResponseError { return error._tag === "BitbucketResponseError" && error.status === 410;} /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. */const MAX_PAGE_SIZE = 50;/** Pages to walk before a listing is reported as truncated. */const MAX_LIST_PAGES = 10;/** The page size for pull request conversations, commits, and checks. */const CONVERSATION_PAGE_SIZE = 50;/** * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, * and an end to a walk whose only other stop is Bitbucket running out. */const CONVERSATION_PAGES = 10;/** The same ceiling the gh and glab diff reads use. */const DIFF_MAX_BYTES = 8 * 1024 * 1024; export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray<BitbucketPullRequest>; readonly truncated: boolean;} export class BitbucketPullRequestApi extends Context.Service< BitbucketPullRequestApi, { /** A function rather than a value, so the request is built per call and not at layer time. */ readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>; readonly listPullRequests: (input: { readonly repository: string; readonly state: PullRequestListState; readonly limit: number; /** Free text, matched against a pull request's title and description. */ readonly query?: string | undefined; /** Where to carry on from, as a predicate on `updated_on` beside any other. */ readonly cursor?: ProviderListCursor | undefined; }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>; readonly getPullRequest: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>; /** True where the credentials can write to the repository, which is what merging needs. */ readonly getRepositoryPermission: (input: { readonly repository: string; }) => Effect.Effect<boolean, BitbucketPullRequestApiError>; readonly getPullRequestDiff: (input: { readonly repository: string; readonly number: number; /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; }) => Effect.Effect< { readonly patch: string; readonly truncated: boolean }, BitbucketPullRequestApiError >; readonly getDiffStat: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>; readonly getMergeability: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>; readonly listComments: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect< { readonly comments: ReadonlyArray<PullRequestComment>; readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean; }, BitbucketPullRequestApiError >; readonly listCommits: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>; readonly listChecks: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>; /** * Who this pull request may be sent to, and who it has already been sent to. Two reads at * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull * request, and neither answers for the other. */ readonly listReviewerCandidates: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>; readonly setReviewerRequest: (input: { readonly repository: string; readonly number: number; readonly reviewers: ReadonlyArray<{ readonly id: string }>; readonly requested: boolean; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly runAction: (input: { readonly repository: string; readonly number: number; readonly action: PullRequestAction; readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly updateChangeRequest: (input: { readonly repository: string; readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly comment: (input: { readonly repository: string; readonly number: number; readonly body: string; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly updateComment: (input: { readonly repository: string; readonly number: number; readonly commentId: string; readonly body: string; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly submitReview: (input: { readonly repository: string; readonly number: number; readonly verdict: PullRequestReviewVerdict; readonly body: string; readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly replyToComment: (input: { readonly repository: string; readonly number: number; readonly commentId: string; readonly body: string; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly setCommentResolution: (input: { readonly repository: string; readonly number: number; readonly commentId: string; readonly resolved: boolean; }) => Effect.Effect<void, BitbucketPullRequestApiError>; }>()("t3/pullRequest/BitbucketPullRequestApi") {} /** `workspace/slug`; Bitbucket has no deeper nesting to address. */function repositorySegments( repository: string,): Result.Result< { readonly workspace: string; readonly slug: string }, BitbucketRepositoryUnsupportedError> { const segments = repository .split("/") .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); const [workspace, slug] = segments; if (segments.length !== 2 || workspace === undefined || slug === undefined) { return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); } return Result.succeed({ workspace, slug });} function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( segments.slug, )}`;} /** * A commit sha arrives from the reader and goes straight into a request path, so it is checked * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a * whole sha. */function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value);} /** * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks * for each. It separates a declined pull request from one superseded by another, and both read * as closed here. */function stateParams(state: PullRequestListState): ReadonlyArray<string> { switch (state) { case "open": return ["OPEN"]; case "merged": return ["MERGED"]; case "closed": return ["DECLINED", "SUPERSEDED"]; case "all": return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; }} /** * Bitbucket has no search term, only a filter expression, so free text becomes one: a * case-insensitive contains against the two fields a pull request carries words in. The * parentheses matter, because the expression is ANDed with the state filter beside it and an * unbracketed `OR` would swallow it. * * A string literal in that grammar is delimited by double quotes, so the reader's text is * escaped before it goes inside one — a quote would otherwise end the literal and leave the * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so * nothing in it reaches the query string as a parameter of its own. */function searchFilter(query: string): string { const literal = filterLiteral(query); return `(title ~ "${literal}" OR description ~ "${literal}")`;} /** * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or * escaping the quote would only produce a literal backslash followed by a live quote. */function filterLiteral(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');} /** Bitbucket's merge strategies, named differently from the three the contract carries. */function mergeStrategy(method: PullRequestMergeMethod | undefined): string { switch (method) { case "squash": return "squash"; case "rebase": // The linear history GitHub calls "rebase and merge". return "rebase_fast_forward"; default: return "merge_commit"; }} function bitbucketReviewPosition( position: PullRequestReviewPosition,): { readonly from: number } | { readonly to: number } { switch (position.kind) { case "added": return { to: position.newLine }; case "deleted": return { from: position.oldLine }; case "context": return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; }} /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; /** * The repository's own path, and the workspace above it — which the people who may review are * kept on rather than on the repository, so both are handed over at once. */ const withRepository = <A>( repository: string, use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>, ): Effect.Effect<A, BitbucketPullRequestApiError> => { const segments = repositorySegments(repository); return Result.isSuccess(segments) ? use(repositoryPathOf(segments.success), segments.success.workspace) : Effect.fail(segments.failure); }; /** * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at * the page cap — and anything but running out of pages means there is more to be had. */ const listPage = (input: { readonly url: string; readonly limit: number; readonly page: number; readonly collected: ReadonlyArray<BitbucketPullRequest>; }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> => bitbucket.request({ method: "GET", url: input.url }).pipe( Effect.flatMap((response) => { const decoded = decodePullRequestPageJson(response.body); if (!Result.isSuccess(decoded)) { return Effect.fail( new BitbucketPullRequestReadError({ operation: "listPullRequests", cause: decoded.failure, }), ); } const collected = [...input.collected, ...decoded.success.items]; const next = decoded.success.next; if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { return Effect.succeed({ items: collected.slice(0, input.limit), // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the // count rather than on the last page is holding rows it is about to drop. Those are // more results just as surely as another page would be. truncated: next !== null || collected.length > input.limit, }); } return listPage({ ...input, url: next, page: input.page + 1, collected }); }), ); const readPage = <A>(input: { readonly operation: string; readonly url: string; readonly decode: (body: string) => Result.Result<A, unknown>; }): Effect.Effect<A, BitbucketPullRequestApiError> => bitbucket.request({ method: "GET", url: input.url }).pipe( Effect.flatMap((response) => { const decoded = input.decode(response.body); return Result.isSuccess(decoded) ? Effect.succeed(decoded.success) : Effect.fail( new BitbucketPullRequestReadError({ operation: input.operation, cause: decoded.failure, }), ); }), ); /** * The conversation, following the `next` Bitbucket sends until it sends none. Threads are * assembled once at the end rather than per page, because a reply and the remark it answers * can land either side of a page boundary. */ const commentsPage = (input: { readonly url: string; readonly page: number; readonly comments: ReadonlyArray<PullRequestComment>; readonly entries: ReadonlyArray<BitbucketRawComment>; }): Effect.Effect< { readonly comments: ReadonlyArray<PullRequestComment>; readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean; }, BitbucketPullRequestApiError > => readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( Effect.flatMap((page) => { const comments = [...input.comments, ...page.comments]; const entries = [...input.entries, ...page.entries]; if (page.next !== null && input.page < CONVERSATION_PAGES) { return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); } return Effect.succeed({ comments, threads: buildReviewThreads(entries), truncated: page.next !== null, }); }), ); /** Walks a Bitbucket cursor to its end and combines every decoded item. */ const itemPages = <A>(input: { readonly operation: string; readonly url: string; readonly decode: ( body: string, ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>; readonly items: ReadonlyArray<A>; /** Commit pages are individually oldest-first, so older pages are prepended. */ readonly prepend: boolean; }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> => readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( Effect.flatMap((page) => {Lines 1–500 of 864. Load more
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, PullRequestCheck, PullRequestComment, PullRequestCommit, PullRequestListState, PullRequestMergeMethod, PullRequestMergeability, PullRequestReviewCommentDraft, PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, } from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import { buildReviewThreads, decodeCommentsJson, decodeCommitsJson, decodeConflictsJson, decodeDiffstatJson, decodePullRequestJson, decodePullRequestPageJson, decodeRepositoryPermissionJson, decodeStatusesJson, decodeViewerJson, decodeWorkspaceMembersJson, type BitbucketDiffStat, type BitbucketPullRequest, type BitbucketRawComment, } from "./bitbucketPullRequestJson.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 BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()( "BitbucketPullRequestReadError", { operation: Schema.String, cause: Schema.Defect(), }, ) { get detail(): string { return `Bitbucket returned an unreadable ${this.operation} response.`; } override get message(): string { return `Bitbucket failed in ${this.operation}: ${this.detail}`; } } /** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()( "BitbucketViewerUnavailableError", {}, ) { get detail(): string { return "Bitbucket returned no account name for the configured credentials."; } override get message(): string { return `Bitbucket failed in getViewer: ${this.detail}`; } } /** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()( "BitbucketRepositoryUnsupportedError", { repository: Schema.String, }, ) { get detail(): string { return "A Bitbucket repository is addressed as workspace/repository."; } override get message(): string { return `Bitbucket failed in resolveRepository: ${this.detail}`; } } /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()( "BitbucketDiffCommitError", {}, ) { get detail(): string { return "The named commit was not a commit sha."; } override get message(): string { return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; } } export type BitbucketPullRequestApiError = | BitbucketApi.BitbucketApiError | BitbucketPullRequestReadError | BitbucketViewerUnavailableError | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; /** * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a * permission — Bitbucket sends this for every account now, not only ones it would have refused. */ function isRepositoryPermissionRemovedError( error: BitbucketPullRequestApiError, ): error is BitbucketApi.BitbucketResponseError { return error._tag === "BitbucketResponseError" && error.status === 410; } /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. */ const MAX_PAGE_SIZE = 50; /** Pages to walk before a listing is reported as truncated. */ const MAX_LIST_PAGES = 10; /** The page size for pull request conversations, commits, and checks. */ const CONVERSATION_PAGE_SIZE = 50; /** * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, * and an end to a walk whose only other stop is Bitbucket running out. */ const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray<BitbucketPullRequest>; readonly truncated: boolean; } export class BitbucketPullRequestApi extends Context.Service< BitbucketPullRequestApi, { /** A function rather than a value, so the request is built per call and not at layer time. */ readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>; readonly listPullRequests: (input: { readonly repository: string; readonly state: PullRequestListState; readonly limit: number; /** Free text, matched against a pull request's title and description. */ readonly query?: string | undefined; /** Where to carry on from, as a predicate on `updated_on` beside any other. */ readonly cursor?: ProviderListCursor | undefined; }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>; readonly getPullRequest: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>; /** True where the credentials can write to the repository, which is what merging needs. */ readonly getRepositoryPermission: (input: { readonly repository: string; }) => Effect.Effect<boolean, BitbucketPullRequestApiError>; readonly getPullRequestDiff: (input: { readonly repository: string; readonly number: number; /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; }) => Effect.Effect< { readonly patch: string; readonly truncated: boolean }, BitbucketPullRequestApiError >; readonly getDiffStat: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>; readonly getMergeability: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>; readonly listComments: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect< { readonly comments: ReadonlyArray<PullRequestComment>; readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean; }, BitbucketPullRequestApiError >; readonly listCommits: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>; readonly listChecks: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>; /** * Who this pull request may be sent to, and who it has already been sent to. Two reads at * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull * request, and neither answers for the other. */ readonly listReviewerCandidates: (input: { readonly repository: string; readonly number: number; }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>; readonly setReviewerRequest: (input: { readonly repository: string; readonly number: number; readonly reviewers: ReadonlyArray<{ readonly id: string }>; readonly requested: boolean; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly runAction: (input: { readonly repository: string; readonly number: number; readonly action: PullRequestAction; readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly updateChangeRequest: (input: { readonly repository: string; readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly comment: (input: { readonly repository: string; readonly number: number; readonly body: string; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly updateComment: (input: { readonly repository: string; readonly number: number; readonly commentId: string; readonly body: string; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly submitReview: (input: { readonly repository: string; readonly number: number; readonly verdict: PullRequestReviewVerdict; readonly body: string; readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly replyToComment: (input: { readonly repository: string; readonly number: number; readonly commentId: string; readonly body: string; }) => Effect.Effect<void, BitbucketPullRequestApiError>; readonly setCommentResolution: (input: { readonly repository: string; readonly number: number; readonly commentId: string; readonly resolved: boolean; }) => Effect.Effect<void, BitbucketPullRequestApiError>; } >()("t3/pullRequest/BitbucketPullRequestApi") {} /** `workspace/slug`; Bitbucket has no deeper nesting to address. */ function repositorySegments( repository: string, ): Result.Result< { readonly workspace: string; readonly slug: string }, BitbucketRepositoryUnsupportedError > { const segments = repository .split("/") .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); const [workspace, slug] = segments; if (segments.length !== 2 || workspace === undefined || slug === undefined) { return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); } return Result.succeed({ workspace, slug }); } function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( segments.slug, )}`; } /** * A commit sha arrives from the reader and goes straight into a request path, so it is checked * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a * whole sha. */ function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value); } /** * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks * for each. It separates a declined pull request from one superseded by another, and both read * as closed here. */ function stateParams(state: PullRequestListState): ReadonlyArray<string> { switch (state) { case "open": return ["OPEN"]; case "merged": return ["MERGED"]; case "closed": return ["DECLINED", "SUPERSEDED"]; case "all": return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; } } /** * Bitbucket has no search term, only a filter expression, so free text becomes one: a * case-insensitive contains against the two fields a pull request carries words in. The * parentheses matter, because the expression is ANDed with the state filter beside it and an * unbracketed `OR` would swallow it. * * A string literal in that grammar is delimited by double quotes, so the reader's text is * escaped before it goes inside one — a quote would otherwise end the literal and leave the * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so * nothing in it reaches the query string as a parameter of its own. */ function searchFilter(query: string): string { const literal = filterLiteral(query); return `(title ~ "${literal}" OR description ~ "${literal}")`; } /** * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or * escaping the quote would only produce a literal backslash followed by a live quote. */ function filterLiteral(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } /** Bitbucket's merge strategies, named differently from the three the contract carries. */ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { switch (method) { case "squash": return "squash"; case "rebase": // The linear history GitHub calls "rebase and merge". return "rebase_fast_forward"; default: return "merge_commit"; } } function bitbucketReviewPosition( position: PullRequestReviewPosition, ): { readonly from: number } | { readonly to: number } { switch (position.kind) { case "added": return { to: position.newLine }; case "deleted": return { from: position.oldLine }; case "context": return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; } } /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; /** * The repository's own path, and the workspace above it — which the people who may review are * kept on rather than on the repository, so both are handed over at once. */ const withRepository = <A>( repository: string, use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>, ): Effect.Effect<A, BitbucketPullRequestApiError> => { const segments = repositorySegments(repository); return Result.isSuccess(segments) ? use(repositoryPathOf(segments.success), segments.success.workspace) : Effect.fail(segments.failure); }; /** * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at * the page cap — and anything but running out of pages means there is more to be had. */ const listPage = (input: { readonly url: string; readonly limit: number; readonly page: number; readonly collected: ReadonlyArray<BitbucketPullRequest>; }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> => bitbucket.request({ method: "GET", url: input.url }).pipe( Effect.flatMap((response) => { const decoded = decodePullRequestPageJson(response.body); if (!Result.isSuccess(decoded)) { return Effect.fail( new BitbucketPullRequestReadError({ operation: "listPullRequests", cause: decoded.failure, }), ); } const collected = [...input.collected, ...decoded.success.items]; const next = decoded.success.next; if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { return Effect.succeed({ items: collected.slice(0, input.limit), // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the // count rather than on the last page is holding rows it is about to drop. Those are // more results just as surely as another page would be. truncated: next !== null || collected.length > input.limit, }); } return listPage({ ...input, url: next, page: input.page + 1, collected }); }), ); const readPage = <A>(input: { readonly operation: string; readonly url: string; readonly decode: (body: string) => Result.Result<A, unknown>; }): Effect.Effect<A, BitbucketPullRequestApiError> => bitbucket.request({ method: "GET", url: input.url }).pipe( Effect.flatMap((response) => { const decoded = input.decode(response.body); return Result.isSuccess(decoded) ? Effect.succeed(decoded.success) : Effect.fail( new BitbucketPullRequestReadError({ operation: input.operation, cause: decoded.failure, }), ); }), ); /** * The conversation, following the `next` Bitbucket sends until it sends none. Threads are * assembled once at the end rather than per page, because a reply and the remark it answers * can land either side of a page boundary. */ const commentsPage = (input: { readonly url: string; readonly page: number; readonly comments: ReadonlyArray<PullRequestComment>; readonly entries: ReadonlyArray<BitbucketRawComment>; }): Effect.Effect< { readonly comments: ReadonlyArray<PullRequestComment>; readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean; }, BitbucketPullRequestApiError > => readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( Effect.flatMap((page) => { const comments = [...input.comments, ...page.comments]; const entries = [...input.entries, ...page.entries]; if (page.next !== null && input.page < CONVERSATION_PAGES) { return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); } return Effect.succeed({ comments, threads: buildReviewThreads(entries), truncated: page.next !== null, }); }), ); /** Walks a Bitbucket cursor to its end and combines every decoded item. */ const itemPages = <A>(input: { readonly operation: string; readonly url: string; readonly decode: ( body: string, ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>; readonly items: ReadonlyArray<A>; /** Commit pages are individually oldest-first, so older pages are prepended. */ readonly prepend: boolean; }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> => readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( Effect.flatMap((page) => {