Open History to see the last commit
1import {
2 makeProviderSettingsSchema,
3 SettingsSecretValue,
4 SourceControlProviderKind,
5 TrimmedString,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9import {
10 buildCommitUrl,
11 changeRequestUrlBelow,
12 defineSourceControl,
13 isChangeRequestInProjectRepository,
14 isChangeRequestOnProjectHost,
15 parseChangeRequestPath,
16 parseCommitPath,
17 repositoryUrlFromPagePath,
18 projectChangeRequestHost,
19} from "@t3tools/source-control-core/shared/definition";
20import { parseUrl } from "@t3tools/shared/url";
21import { icon } from "./icon.ts";
22
23const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
24const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
25
26const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u;
27const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/commits\/([0-9a-f]+)\/?$/iu;
28const REPOSITORY_PAGE_PATH = /^\/([^/]+\/[^/]+)\/src\/[^/]/u;
29
30const KIND = SourceControlProviderKind.make("bitbucket");
31
32function isBitbucketHost(hostname: string): boolean {
33 const host = hostname.toLowerCase();
34 return (
35 host === "bitbucket.org" ||
36 host.endsWith(".bitbucket.org") ||
37 host.split(".").includes("bitbucket")
38 );
39}
40
41const text = (annotations: Schema.Annotations.Key<string>) =>
42 TrimmedString.pipe(
43 Schema.withDecodingDefault(Effect.succeed("")),
44 Schema.annotateKey(annotations),
45 );
46
47const secret = (annotations: Schema.Annotations.Key<SettingsSecretValue>) =>
48 SettingsSecretValue.pipe(
49 Schema.withDecodingDefault(Effect.succeed("")),
50 Schema.annotateKey({ ...annotations, providerSettingsForm: { control: "password" } }),
51 );
52
53export const settings = makeProviderSettingsSchema(
54 {
55 accessToken: secret({
56 title: "Access token",
57 description:
58 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
59 }),
60 email: text({
61 title: "Atlassian account email",
62 description: "With an API token instead of an access token.",
63 providerSettingsForm: { placeholder: "you@example.com" },
64 }),
65 apiToken: secret({
66 title: "API token",
67 description:
68 "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, plus read:user:bitbucket and read:workspace:bitbucket.",
69 }),
70 },
71 { order: ["accessToken", "email", "apiToken"] },
72);
73export type BitbucketSettings = typeof settings.Type;
74
75export const definition = defineSourceControl({
76 kind: KIND,
77 label: "Bitbucket",
78 pickerLabel: "Bitbucket",
79 icon,
80 changeRequest: {
81 shortLabel: "PR",
82 shortLabelWithArticle: "a PR",
83 singular: "pull request",
84 plural: "pull requests",
85 },
86 repositoryPathHint: "workspace/repository",
87 publicHost: "bitbucket.org",
88 publishDescription: "bitbucket.org",
89 publishHost: () => "bitbucket.org",
90 // The signed-in account does not identify a workspace.
91 newRepositoryOwner: () => null,
92 defaultCloneTransport: "ssh",
93 changeRequestUrl: ({ host, repository, number, webUrl }) =>
94 webUrl
95 ? changeRequestUrlBelow(webUrl, `pull-requests/${number}`)
96 : `https://${host}/${repository}/pull-requests/${number}`,
97 parseChangeRequestUrl: (url) =>
98 isBitbucketHost(url.hostname) ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) : null,
99 commitUrl: (input) => buildCommitUrl(input, "commits"),
100 parseCommitUrl: (url) =>
101 isBitbucketHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null,
102 repositoryUrlFromPage: (url) =>
103 isBitbucketHost(url.hostname) ? repositoryUrlFromPagePath(url, REPOSITORY_PAGE_PATH) : null,
104 changeRequestUrlFromReferenceAutolink: () => null,
105 // No endpoint reopens a declined pull request, and nothing documented moves one in or out of
106 // draft, so neither is offered rather than failing when pressed.
107 changeRequestActions: new Set(["merge", "close"] as const),
108 // Bitbucket has no checkout CLI.
109 checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) =>
110 headRepositoryNameWithOwner &&
111 repositoryName.test(headRepositoryNameWithOwner) &&
112 safeShellArgument.test(headBranch)
113 ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`
114 : null,
115 authorProfileUrl: () => null,
116 reviewSummaryRequired: () => false,
117 checkoutCommandArgument: () => null,
118 isChangeRequestReference: (reference) => {
119 const url = parseUrl(reference);
120 return (
121 url !== null &&
122 isBitbucketHost(url.hostname) &&
123 parseChangeRequestPath(url, CHANGE_REQUEST_PATH) !== null
124 );
125 },
126 checkoutChangeRequestHost: () => null,
127 recognizeRemote: ({ host, hostname }) =>
128 hostname === "bitbucket.org" || hostname.split(".").includes("bitbucket")
129 ? {
130 kind: KIND,
131 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
132 baseUrl: `https://${host}`,
133 }
134 : null,
135 isChangeRequestInRepository: (identity, link) =>
136 isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link),
137 canReadChangeRequestOnHost: (identity, link) =>
138 isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link),
139 settings,
140});