diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx index feddf6cd8f2160..47cce7c49d5822 100644 --- a/components/dashboard/src/projects/NewProject.tsx +++ b/components/dashboard/src/projects/NewProject.tsx @@ -141,7 +141,7 @@ export default function NewProject() { }, [selectedAccount]); useEffect(() => { - if (!selectedProviderHost || isBitbucket()) { + if (!selectedProviderHost) { return; } (async () => { @@ -161,12 +161,11 @@ export default function NewProject() { }, [project, sourceOfConfig]); const isGitHub = () => selectedProviderHost === "github.com"; - const isBitbucket = () => selectedProviderHost === "bitbucket.org"; const updateReposInAccounts = async (installationId?: string) => { setLoaded(false); setReposInAccounts([]); - if (!selectedProviderHost || isBitbucket()) { + if (!selectedProviderHost) { return []; } try { @@ -194,7 +193,7 @@ export default function NewProject() { } const createProject = async (teamOrUser: Team | User, repo: ProviderRepository) => { - if (!selectedProviderHost || isBitbucket()) { + if (!selectedProviderHost) { return; } const repoSlug = repo.path || repo.name; @@ -382,11 +381,11 @@ export default function NewProject() { setSelectedProviderHost(host); } - if (!loaded && !isBitbucket()) { + if (!loaded) { return renderLoadingState(); } - if (showGitProviders || isBitbucket()) { + if (showGitProviders) { return (); } @@ -437,18 +436,6 @@ export default function NewProject() { ) }; - const renderBitbucketWarning = () => { - return ( -
-
- -
-
-

Bitbucket support for projects is not available yet. Follow #5980 for updates.

-
-
); - } - const onNewWorkspace = async () => { const redirectToNewWorkspace = () => { // instead of `history.push` we want forcibly to redirect here in order to avoid a following redirect from `/` -> `/projects` (cf. App.tsx) @@ -473,8 +460,6 @@ export default function NewProject() { {selectedRepo && selectedTeamOrUser && (
)} - {isBitbucket() && renderBitbucketWarning()} - ); } else { const projectLink = User.is(selectedTeamOrUser) ? `/projects/${project.slug}` : `/t/${selectedTeamOrUser?.slug}/${project.slug}`; @@ -534,8 +519,8 @@ function GitProviders(props: { }); } - // for now we exclude bitbucket.org and GitHub Enterprise - const filteredProviders = () => props.authProviders.filter(p => p.host === "github.com" || p.authProviderType === "GitLab"); + // for now we exclude GitHub Enterprise + const filteredProviders = () => props.authProviders.filter(p => p.host === "github.com" || p.host === "bitbucket.org" || p.authProviderType === "GitLab"); return (
diff --git a/components/server/ee/src/bitbucket/bitbucket-app-support.ts b/components/server/ee/src/bitbucket/bitbucket-app-support.ts new file mode 100644 index 00000000000000..2b5a8bedb84eec --- /dev/null +++ b/components/server/ee/src/bitbucket/bitbucket-app-support.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2020 Gitpod GmbH. All rights reserved. + * Licensed under the Gitpod Enterprise Source Code License, + * See License.enterprise.txt in the project root folder. + */ + +import { AuthProviderInfo, ProviderRepository, User } from "@gitpod/gitpod-protocol"; +import { inject, injectable } from "inversify"; +import { TokenProvider } from "../../../src/user/token-provider"; +import { Bitbucket } from "bitbucket"; +import { URL } from "url"; + +@injectable() +export class BitbucketAppSupport { + + @inject(TokenProvider) protected readonly tokenProvider: TokenProvider; + + async getProviderRepositoriesForUser(params: { user: User, provider: AuthProviderInfo }): Promise { + const token = await this.tokenProvider.getTokenForHost(params.user, params.provider.host); + const oauthToken = token.value; + + const api = new Bitbucket({ + baseUrl: `https://api.${params.provider.host}/2.0`, + auth: { + token: oauthToken + } + }); + + const result: ProviderRepository[] = []; + const ownersRepos: ProviderRepository[] = []; + + const identity = params.user.identities.find(i => i.authProviderId === params.provider.authProviderId); + if (!identity) { + return result; + } + const usersBitbucketAccount = identity.authName; + + const workspaces = (await api.workspaces.getWorkspaces({ pagelen: 100 })).data.values?.map(w => w.slug!) || []; + + const reposPromise = Promise.all(workspaces.map(workspace => api.repositories.list({ + workspace, + pagelen: 100, + role: "admin" // installation of webhooks is allowed for admins only + }).catch(e => { + console.error(e) + }))); + + const reposInWorkspace = await reposPromise; + for (const repos of reposInWorkspace) { + if (repos) { + for (const repo of (repos.data.values || [])) { + let cloneUrl = repo.links!.clone!.find((x: any) => x.name === "https")!.href!; + if (cloneUrl) { + const url = new URL(cloneUrl); + url.username = ''; + cloneUrl = url.toString(); + } + const fullName = repo.full_name!; + const updatedAt = repo.updated_on!; + const accountAvatarUrl = repo.links!.avatar?.href!; + const account = fullName.split("/")[0]; + + (account === usersBitbucketAccount ? ownersRepos : result).push({ + name: repo.name!, + account, + cloneUrl, + updatedAt, + accountAvatarUrl, + }) + } + } + } + + // put owner's repos first. the frontend will pick first account to continue with + result.unshift(...ownersRepos); + return result; + } + +} \ No newline at end of file diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 1f61fd66a0ce29..8c9c96d35a7da7 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -50,6 +50,7 @@ import { GitHubAppSupport } from "./github/github-app-support"; import { GitLabAppSupport } from "./gitlab/gitlab-app-support"; import { Config } from "../../src/config"; import { SnapshotService } from "./workspace/snapshot-service"; +import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support"; export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { rebind(Server).to(ServerEE).inSingletonScope(); @@ -68,6 +69,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(GitLabApp).toSelf().inSingletonScope(); bind(GitLabAppSupport).toSelf().inSingletonScope(); bind(BitbucketApp).toSelf().inSingletonScope(); + bind(BitbucketAppSupport).toSelf().inSingletonScope(); bind(LicenseEvaluator).toSelf().inSingletonScope(); bind(LicenseKeySource).to(DBLicenseKeySource).inSingletonScope(); diff --git a/components/server/ee/src/prebuilds/bitbucket-service.ts b/components/server/ee/src/prebuilds/bitbucket-service.ts index afa1328cbc2722..48af8c8c47d8ff 100644 --- a/components/server/ee/src/prebuilds/bitbucket-service.ts +++ b/components/server/ee/src/prebuilds/bitbucket-service.ts @@ -27,7 +27,17 @@ export class BitbucketService extends RepositoryService { async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise { const { host } = await this.bitbucketContextParser.parseURL(user, cloneUrl); - return host === this.authProviderConfig.host; + if (host !== this.authProviderConfig.host) { + return false; + } + + // only admins may install webhooks on repositories + const { owner, repoName } = await this.bitbucketContextParser.parseURL(user, cloneUrl); + const api = await this.api.create(user); + const response = await api.user.listPermissionsForRepos({ + q: `repository.full_name="${owner}/${repoName}"` + }) + return !!response.data?.values && response.data.values[0]?.permission === "admin"; } async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise { diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index f3e6a57babcb25..e56ebee99cb8ce 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -41,6 +41,7 @@ import { Config } from "../../../src/config"; import { SnapshotService, WaitForSnapshotOptions } from "./snapshot-service"; import { SafePromise } from "@gitpod/gitpod-protocol/lib/util/safe-promise"; import { ClientMetadata } from "../../../src/websocket/websocket-connection-manager"; +import { BitbucketAppSupport } from "../bitbucket/bitbucket-app-support"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { @@ -68,6 +69,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { @inject(GitHubAppSupport) protected readonly githubAppSupport: GitHubAppSupport; @inject(GitLabAppSupport) protected readonly gitLabAppSupport: GitLabAppSupport; + @inject(BitbucketAppSupport) protected readonly bitbucketAppSupport: BitbucketAppSupport; @inject(Config) protected readonly config: Config; @@ -1429,6 +1431,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl { if (providerHost === "github.com") { repositories.push(...(await this.githubAppSupport.getProviderRepositoriesForUser({ user, ...params }))); + } else if (providerHost === "bitbucket.org" && provider) { + repositories.push(...(await this.bitbucketAppSupport.getProviderRepositoriesForUser({ user, provider }))); } else if (provider?.authProviderType === "GitLab") { repositories.push(...(await this.gitLabAppSupport.getProviderRepositoriesForUser({ user, provider }))); } else { diff --git a/components/server/package.json b/components/server/package.json index e03918659e9203..a93cdcae8d0b00 100644 --- a/components/server/package.json +++ b/components/server/package.json @@ -44,7 +44,7 @@ "@probot/get-private-key": "^1.1.1", "amqplib": "^0.8.0", "base-64": "^1.0.0", - "bitbucket": "^2.4.2", + "bitbucket": "^2.7.0", "body-parser": "^1.18.2", "cookie": "^0.4.1", "cookie-parser": "^1.4.5", diff --git a/components/server/src/bitbucket/bitbucket-context-parser.ts b/components/server/src/bitbucket/bitbucket-context-parser.ts index 876e6b8ebdb0e1..36d36205a157ae 100644 --- a/components/server/src/bitbucket/bitbucket-context-parser.ts +++ b/components/server/src/bitbucket/bitbucket-context-parser.ts @@ -250,8 +250,6 @@ export class BitbucketContextParser extends AbstractContextParser implements ICo const result: Repository = { cloneUrl: `https://${host}/${repo.full_name}.git`, - // cloneUrl: repoQueryResult.links.html.href + ".git", - // cloneUrl: repoQueryResult.links.clone.find((x: any) => x.name === "https").href, host, name, owner, diff --git a/components/server/src/bitbucket/bitbucket-repository-provider.ts b/components/server/src/bitbucket/bitbucket-repository-provider.ts index e62da2084c0537..188396b5a2059f 100644 --- a/components/server/src/bitbucket/bitbucket-repository-provider.ts +++ b/components/server/src/bitbucket/bitbucket-repository-provider.ts @@ -6,6 +6,7 @@ import { Branch, CommitInfo, Repository, User } from "@gitpod/gitpod-protocol"; import { inject, injectable } from 'inversify'; +import { URL } from "url"; import { RepoURL } from '../repohost/repo-url'; import { RepositoryProvider } from '../repohost/repository-provider'; import { BitbucketApiFactory } from './bitbucket-api-factory'; @@ -18,7 +19,12 @@ export class BitbucketRepositoryProvider implements RepositoryProvider { async getRepo(user: User, owner: string, name: string): Promise { const api = await this.apiFactory.create(user); const repo = (await api.repositories.get({ workspace: owner, repo_slug: name })).data; - const cloneUrl = repo.links!.clone!.find((x: any) => x.name === "https")!.href!; + let cloneUrl = repo.links!.clone!.find((x: any) => x.name === "https")!.href!; + if (cloneUrl) { + const url = new URL(cloneUrl); + url.username = ''; + cloneUrl = url.toString(); + } const host = RepoURL.parseRepoUrl(cloneUrl)!.host; const description = repo.description; const avatarUrl = repo.owner!.links!.avatar!.href; @@ -26,18 +32,69 @@ export class BitbucketRepositoryProvider implements RepositoryProvider { return { host, owner, name, cloneUrl, description, avatarUrl, webUrl }; } - async getBranch(user: User, owner: string, repo: string, branch: string): Promise { - // todo - throw new Error("not implemented"); + async getBranch(user: User, owner: string, repo: string, branchName: string): Promise { + const api = await this.apiFactory.create(user); + const response = await api.repositories.getBranch({ + workspace: owner, + repo_slug: repo, + name: branchName + }) + + const branch = response.data; + + return { + htmlUrl: branch.links?.html?.href!, + name: branch.name!, + commit: { + sha: branch.target?.hash!, + author: branch.target?.author?.user?.display_name!, + authorAvatarUrl: branch.target?.author?.user?.links?.avatar?.href, + authorDate: branch.target?.date!, + commitMessage: branch.target?.message || "missing commit message", + } + }; } async getBranches(user: User, owner: string, repo: string): Promise { - // todo - return []; + const branches: Branch[] = []; + const api = await this.apiFactory.create(user); + const response = await api.repositories.listBranches({ + workspace: owner, + repo_slug: repo, + sort: "target.date" + }) + + for (const branch of response.data.values!) { + branches.push({ + htmlUrl: branch.links?.html?.href!, + name: branch.name!, + commit: { + sha: branch.target?.hash!, + author: branch.target?.author?.user?.display_name!, + authorAvatarUrl: branch.target?.author?.user?.links?.avatar?.href, + authorDate: branch.target?.date!, + commitMessage: branch.target?.message || "missing commit message", + } + }); + } + + return branches; } async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise { - // todo - return undefined; + const api = await this.apiFactory.create(user); + const response = await api.commits.get({ + workspace: owner, + repo_slug: repo, + commit: ref + }) + const commit = response.data; + return { + sha: commit.hash!, + author: commit.author?.user?.display_name!, + authorDate: commit.date!, + commitMessage: commit.message || "missing commit message", + authorAvatarUrl: commit.author?.user?.links?.avatar?.href, + }; } } diff --git a/yarn.lock b/yarn.lock index 8a08e47eb00c38..e0f4adc94df7d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5041,10 +5041,10 @@ bintrees@1.0.1: resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= -bitbucket@^2.4.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/bitbucket/-/bitbucket-2.6.3.tgz#e7aa030406720e24c19a40701506b1c366daf544" - integrity sha512-t23mlPsCchl+7TCGGHqI4Up++mnGd6smaKsNe/t+kGlkGfIzm+QmVdWvBboHl8Nyequ8Wm0Whi2lKq9qmfJmxA== +bitbucket@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/bitbucket/-/bitbucket-2.7.0.tgz#fd11b19a42cc9b89f6a899ff669fd1575183a5b3" + integrity sha512-6fw3MzXeFp3TLmo6jF7IWFn9tFpFKpzCpDjKek9s5EY559Ff3snbu2hmS5ZKmR7D0XomPbIT0dBN1juoJ/gGyA== dependencies: before-after-hook "^2.1.0" deepmerge "^4.2.2"