1/**
2 * Bitbucket's client definition. Browser- and React Native-safe.
3 *
4 * @module source-control-bitbucket/client/definition
5 */
6import {
7 makeProviderSettingsSchema,
8 SourceControlProviderKind,
9 TrimmedString,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
13import {
14 defineSourceControlClient,
15 isChangeRequestInProjectRepository,
16 isChangeRequestOnProjectHost,
17 isChangeRequestPath,
18} from "@t3tools/source-control-core/client/definition";
19
20const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
21const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
22
23const KIND = SourceControlProviderKind.make("bitbucket");
24
25const text = (annotations: Schema.Annotations.Key<string>) =>
26 TrimmedString.pipe(
27 Schema.withDecodingDefault(Effect.succeed("")),
28 Schema.annotateKey(annotations),
29 );
30
31/**
32 * Bitbucket API credentials, used before the `T3CODE_BITBUCKET_*` environment variables: an
33 * access token, or an Atlassian account email with an API token. The access token wins when both
34 * are saved.
35 */
36export const settings = makeProviderSettingsSchema(
37 {
38 accessToken: text({
39 title: "Access token",
40 description:
41 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
42 settingsSecret: true,
43 providerSettingsForm: { control: "password" },
44 }),
45 email: text({
46 title: "Atlassian account email",
47 description: "With an API token instead of an access token.",
48 providerSettingsForm: { placeholder: "you@example.com" },
49 }),
50 apiToken: text({
51 title: "API token",
52 description:
53 "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.",
54 settingsSecret: true,
55 providerSettingsForm: { control: "password" },
56 }),
57 },
58 { order: ["accessToken", "email", "apiToken"] },
59);
60export type BitbucketSettings = typeof settings.Type;
61
62export const definition = defineSourceControlClient({
63 kind: KIND,
64 label: "Bitbucket",
65 pickerLabel: "Bitbucket",
66 icon: "bitbucket",
67 changeRequest: { shortLabel: "PR", singular: "pull request" },
68 repositoryPathHint: "workspace/repository",
69 publicHost: "bitbucket.org",
70 publishDescription: "bitbucket.org",
71 publishHost: () => "bitbucket.org",
72 // A new repository belongs to a workspace, which the signed-in account does not name.
73 newRepositoryOwner: () => null,
74 defaultCloneTransport: "ssh",
75 changeRequestUrl: ({ host, repository, number }) =>
76 `https://${host}/${repository}/pull-requests/${number}`,
77 // No endpoint reopens a declined pull request, and nothing documented moves one in or out of
78 // draft, so neither is offered rather than failing when pressed.
79 changeRequestActions: new Set(["merge", "close"] as const),
80 // Bitbucket has no checkout CLI, so clone the head branch from its own repository.
81 checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) =>
82 headRepositoryNameWithOwner &&
83 repositoryName.test(headRepositoryNameWithOwner) &&
84 safeShellArgument.test(headBranch)
85 ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`
86 : null,
87 authorProfileUrl: () => null,
88 referenceAutolinkRepositoryUrl: () => null,
89 reviewSummaryRequired: () => false,
90 // No checkout CLI, and the reference field never accepted Bitbucket URLs.
91 checkoutCommandArgument: () => null,
92 isChangeRequestReference: () => false,
93 changeRequestUrlHost: (url) => url.hostname,
94 checkoutChangeRequestHost: () => null,
95 isChangeRequestInRepository: (identity, link) =>
96 isChangeRequestInProjectRepository(KIND, identity, link),
97 canReadChangeRequestOnHost: (identity, link) =>
98 isChangeRequestOnProjectHost(KIND, identity, link),
99 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),
100 settings,
101});