Skip to content

[bitbucket] enable projects #7251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 7 additions & 22 deletions components/dashboard/src/projects/NewProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default function NewProject() {
}, [selectedAccount]);

useEffect(() => {
if (!selectedProviderHost || isBitbucket()) {
if (!selectedProviderHost) {
return;
}
(async () => {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -382,11 +381,11 @@ export default function NewProject() {
setSelectedProviderHost(host);
}

if (!loaded && !isBitbucket()) {
if (!loaded) {
return renderLoadingState();
}

if (showGitProviders || isBitbucket()) {
if (showGitProviders) {
return (<GitProviders onHostSelected={onGitProviderSeleted} authProviders={authProviders} />);
}

Expand Down Expand Up @@ -437,18 +436,6 @@ export default function NewProject() {
</>)
};

const renderBitbucketWarning = () => {
return (
<div className="mt-16 flex space-x-2 py-6 px-6 w-96 justify-betweeen bg-gitpod-kumquat-light rounded-xl">
<div className="pr-3 self-center w-6">
<img src={exclamation} />
</div>
<div className="flex-1 flex flex-col">
<p className="text-gitpod-red text-sm">Bitbucket support for projects is not available yet. Follow <a className="gp-link" href="https://github.com/gitpod-io/gitpod/issues/5980">#5980</a> for updates.</p>
</div>
</div>);
}

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)
Expand All @@ -473,8 +460,6 @@ export default function NewProject() {
{selectedRepo && selectedTeamOrUser && (<div></div>)}
</>

{isBitbucket() && renderBitbucketWarning()}

</div>);
} else {
const projectLink = User.is(selectedTeamOrUser) ? `/projects/${project.slug}` : `/t/${selectedTeamOrUser?.slug}/${project.slug}`;
Expand Down Expand Up @@ -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 (
<div className="mt-8 border rounded-t-xl border-gray-100 dark:border-gray-800 flex-col">
Expand Down
79 changes: 79 additions & 0 deletions components/server/ee/src/bitbucket/bitbucket-app-support.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderRepository[]> {
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;
}

}
2 changes: 2 additions & 0 deletions components/server/ee/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion components/server/ee/src/prebuilds/bitbucket-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,17 @@ export class BitbucketService extends RepositoryService {

async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise<boolean> {
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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This could be considered out of the scope of this PR, but in contrast with GitLab and GitHub repositories, adding a Bitbucket repository 🅰️ does not add a webhook for enabling prebuilds and 🅱️ does not automatically trigger a first prebuilds after adding the project.

🍊 🍊 🍊 🍊

thought: This is also interesting to test and make it clearler if separating the steps adding a project and enabling prebuilds makes sense. Re-posting from #7031 (comment) for visibility:

Eventually we will most probably need to separate project addition and project configuration steps so that these can be done separastely. 💭

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I'll have a look into this. Thanks @gtsiolis!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi: Opened #7367 to track this. Cc @AlexTugarev @jldec

}

async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> {
Expand Down
4 changes: 4 additions & 0 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion components/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 0 additions & 2 deletions components/server/src/bitbucket/bitbucket-context-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,26 +19,82 @@ export class BitbucketRepositoryProvider implements RepositoryProvider {
async getRepo(user: User, owner: string, name: string): Promise<Repository> {
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;
const webUrl = repo.links!.html!.href;
return { host, owner, name, cloneUrl, description, avatarUrl, webUrl };
}

async getBranch(user: User, owner: string, repo: string, branch: string): Promise<Branch> {
// todo
throw new Error("not implemented");
async getBranch(user: User, owner: string, repo: string, branchName: string): Promise<Branch> {
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<Branch[]> {
// 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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: So jealous the commit author avatar is displayed for Bitbucket repositories but not for GitLab and GitHub repositories. 😭

question: Definitely out of the scope of this PR, but is this as simple to fix for GitLab and GitHub repositories? Happy to also open a follow up issue for this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately, nope! they don't carry it like here.

authorDate: branch.target?.date!,
commitMessage: branch.target?.message || "missing commit message",
}
});
}

return branches;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Although branches are successfully retrieved, we 🅰️ don't have the default label for the default branch and 🅱️ we don't pin the default branch at the top of the branches list. Known? If this is more challenging to resolve due to Bitbucket's API, let's open a follow up issue for this if needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi: Opened #7366 to track this. Cc @AlexTugarev @jldec

}

async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise<CommitInfo | undefined> {
// 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,
};
}
}
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down