Skip to content

[bitbucket-server] support for projects and prebuilds #8896

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
Apr 4, 2022
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
37 changes: 24 additions & 13 deletions components/dashboard/src/projects/NewProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,26 @@ export default function NewProject() {
const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]);

useEffect(() => {
if (user && selectedProviderHost === undefined) {
if (user.identities.find((i) => i.authProviderId === "Public-GitLab")) {
setSelectedProviderHost("gitlab.com");
} else if (user.identities.find((i) => i.authProviderId === "Public-GitHub")) {
setSelectedProviderHost("github.com");
} else if (user.identities.find((i) => i.authProviderId === "Public-Bitbucket")) {
setSelectedProviderHost("bitbucket.org");
(async () => {
setAuthProviders(await getGitpodService().server.getAuthProviders());
})();
}, []);

useEffect(() => {
if (user && authProviders && selectedProviderHost === undefined) {
for (let i = user.identities.length - 1; i >= 0; i--) {
const candidate = user.identities[i];
if (candidate) {
const authProvider = authProviders.find((ap) => ap.authProviderId === candidate.authProviderId);
const host = authProvider?.host;
if (host) {
setSelectedProviderHost(host);
break;
}
}
}
(async () => {
setAuthProviders(await getGitpodService().server.getAuthProviders());
})();
}
}, [user]);
}, [user, authProviders]);

useEffect(() => {
const params = new URLSearchParams(location.search);
Expand Down Expand Up @@ -385,7 +392,7 @@ export default function NewProject() {
>
{toSimpleName(r.name)}
</div>
<p>Updated {moment(r.updatedAt).fromNow()}</p>
{r.updatedAt && <p>Updated {moment(r.updatedAt).fromNow()}</p>}
</div>
<div className="flex justify-end">
<div className="h-full my-auto flex self-center opacity-0 group-hover:opacity-100 items-center mr-2 text-right">
Expand Down Expand Up @@ -653,7 +660,11 @@ function GitProviders(props: {

const filteredProviders = () =>
props.authProviders.filter(
(p) => p.authProviderType === "GitHub" || p.host === "bitbucket.org" || p.authProviderType === "GitLab",
(p) =>
p.authProviderType === "GitHub" ||
p.host === "bitbucket.org" ||
p.authProviderType === "GitLab" ||
p.authProviderType === "BitbucketServer",
);

return (
Expand Down
2 changes: 1 addition & 1 deletion components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export interface ProviderRepository {
account: string;
accountAvatarUrl: string;
cloneUrl: string;
updatedAt: string;
updatedAt?: string;
installationId?: number;
installationUpdatedAt?: string;

Expand Down
2 changes: 2 additions & 0 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,8 @@ export interface Repository {
owner: string;
name: string;
cloneUrl: string;
/* Optional kind to differentiate between repositories of orgs/groups/projects and personal repos. */
repoKind?: string;
description?: string;
avatarUrl?: string;
webUrl?: string;
Expand Down
6 changes: 3 additions & 3 deletions components/server/ee/src/auth/host-container-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HostContainerMapping } from "../../../src/auth/host-container-mapping";
import { gitlabContainerModuleEE } from "../gitlab/container-module";
import { bitbucketContainerModuleEE } from "../bitbucket/container-module";
import { gitHubContainerModuleEE } from "../github/container-module";
import { bitbucketServerContainerModuleEE } from "../bitbucket-server/container-module";

@injectable()
export class HostContainerMappingEE extends HostContainerMapping {
Expand All @@ -20,9 +21,8 @@ export class HostContainerMappingEE extends HostContainerMapping {
return (modules || []).concat([gitlabContainerModuleEE]);
case "Bitbucket":
return (modules || []).concat([bitbucketContainerModuleEE]);
// case "BitbucketServer":
// FIXME
// return (modules || []).concat([bitbucketContainerModuleEE]);
case "BitbucketServer":
return (modules || []).concat([bitbucketServerContainerModuleEE]);
case "GitHub":
return (modules || []).concat([gitHubContainerModuleEE]);
default:
Expand Down
13 changes: 13 additions & 0 deletions components/server/ee/src/bitbucket-server/container-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* 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 { ContainerModule } from "inversify";
import { RepositoryService } from "../../../src/repohost/repo-service";
import { BitbucketServerService } from "../prebuilds/bitbucket-server-service";

export const bitbucketServerContainerModuleEE = new ContainerModule((_bind, _unbind, _isBound, rebind) => {
rebind(RepositoryService).to(BitbucketServerService).inSingletonScope();
});
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 @@ -58,6 +58,7 @@ import { Config } from "../../src/config";
import { SnapshotService } from "./workspace/snapshot-service";
import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support";
import { UserCounter } from "./user/user-counter";
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";

export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
rebind(Server).to(ServerEE).inSingletonScope();
Expand All @@ -77,6 +78,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
bind(BitbucketApp).toSelf().inSingletonScope();
bind(BitbucketAppSupport).toSelf().inSingletonScope();
bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
bind(BitbucketServerApp).toSelf().inSingletonScope();

bind(UserCounter).toSelf().inSingletonScope();

Expand Down
244 changes: 244 additions & 0 deletions components/server/ee/src/prebuilds/bitbucket-server-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/

import * as express from "express";
import { postConstruct, injectable, inject } from "inversify";
import { ProjectDB, TeamDB, UserDB } from "@gitpod/gitpod-db/lib";
import { PrebuildManager } from "../prebuilds/prebuild-manager";
import { TokenService } from "../../../src/user/token-service";
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { CommitContext, CommitInfo, Project, StartPrebuildResult, User } from "@gitpod/gitpod-protocol";
import { RepoURL } from "../../../src/repohost";
import { HostContextProvider } from "../../../src/auth/host-context-provider";
import { ContextParser } from "../../../src/workspace/context-parser-service";

@injectable()
export class BitbucketServerApp {
@inject(UserDB) protected readonly userDB: UserDB;
@inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager;
@inject(TokenService) protected readonly tokenService: TokenService;
@inject(ProjectDB) protected readonly projectDB: ProjectDB;
@inject(TeamDB) protected readonly teamDB: TeamDB;
@inject(ContextParser) protected readonly contextParser: ContextParser;
@inject(HostContextProvider) protected readonly hostCtxProvider: HostContextProvider;

protected _router = express.Router();
public static path = "/apps/bitbucketserver/";

@postConstruct()
protected init() {
this._router.post("/", async (req, res) => {
try {
const payload = req.body;
if (PushEventPayload.is(req.body)) {
const span = TraceContext.startSpan("BitbucketApp.handleEvent", {});
let queryToken = req.query["token"] as string;
if (typeof queryToken === "string") {
queryToken = decodeURIComponent(queryToken);
}
const user = await this.findUser({ span }, queryToken);
if (!user) {
// If the webhook installer is no longer found in Gitpod's DB
// we should send a UNAUTHORIZED signal.
res.statusCode = 401;
res.send();
return;
}
await this.handlePushHook({ span }, user, payload);
} else {
console.warn(`Ignoring unsupported BBS event.`, { headers: req.headers });
}
} catch (err) {
console.error(`Couldn't handle request.`, err, { headers: req.headers, reqBody: req.body });
} finally {
// we always respond with OK, when we received a valid event.
res.sendStatus(200);
}
});
}

protected async findUser(ctx: TraceContext, secretToken: string): Promise<User> {
const span = TraceContext.startSpan("BitbucketApp.findUser", ctx);
try {
span.setTag("secret-token", secretToken);
const [userid, tokenValue] = secretToken.split("|");
const user = await this.userDB.findUserById(userid);
if (!user) {
throw new Error("No user found for " + secretToken + " found.");
} else if (!!user.blocked) {
throw new Error(`Blocked user ${user.id} tried to start prebuild.`);
}
const identity = user.identities.find((i) => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID);
if (!identity) {
throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`);
}
const tokens = await this.userDB.findTokensForIdentity(identity);
const token = tokens.find((t) => t.token.value === tokenValue);
if (!token) {
throw new Error(`User ${user.id} has no token with given value.`);
}
return user;
} finally {
span.finish();
}
}

protected async handlePushHook(
ctx: TraceContext,
user: User,
event: PushEventPayload,
): Promise<StartPrebuildResult | undefined> {
const span = TraceContext.startSpan("Bitbucket.handlePushHook", ctx);
try {
const contextUrl = this.createContextUrl(event);
span.setTag("contextUrl", contextUrl);
const context = await this.contextParser.handle({ span }, user, contextUrl);
if (!CommitContext.is(context)) {
throw new Error("CommitContext exprected.");
}
const cloneUrl = context.repository.cloneUrl;
const commit = context.revision;
const projectAndOwner = await this.findProjectAndOwner(cloneUrl, user);
const config = await this.prebuildManager.fetchConfig({ span }, user, context);
if (!this.prebuildManager.shouldPrebuild(config)) {
console.log("Bitbucket push event: No config. No prebuild.");
return undefined;
}

console.debug("Bitbucket Server push event: Starting prebuild.", { contextUrl });

const commitInfo = await this.getCommitInfo(user, cloneUrl, commit);

const ws = await this.prebuildManager.startPrebuild(
{ span },
{
user: projectAndOwner.user,
project: projectAndOwner?.project,
context,
commitInfo,
},
);
return ws;
} finally {
span.finish();
}
}

private async getCommitInfo(user: User, repoURL: string, commitSHA: string) {
const parsedRepo = RepoURL.parseRepoUrl(repoURL)!;
const hostCtx = this.hostCtxProvider.get(parsedRepo.host);
let commitInfo: CommitInfo | undefined;
if (hostCtx?.services?.repositoryProvider) {
commitInfo = await hostCtx?.services?.repositoryProvider.getCommitInfo(
user,
parsedRepo.owner,
parsedRepo.repo,
commitSHA,
);
}
return commitInfo;
}

/**
* Finds the relevant user account and project to the provided webhook event information.
*
* First of all it tries to find the project for the given `cloneURL`, then it tries to
* find the installer, which is also supposed to be a team member. As a fallback, it
* looks for a team member which also has a bitbucket.org connection.
*
* @param cloneURL of the webhook event
* @param webhookInstaller the user account known from the webhook installation
* @returns a promise which resolves to a user account and an optional project.
*/
protected async findProjectAndOwner(
cloneURL: string,
webhookInstaller: User,
): Promise<{ user: User; project?: Project }> {
const project = await this.projectDB.findProjectByCloneUrl(cloneURL);
if (project) {
if (project.userId) {
const user = await this.userDB.findUserById(project.userId);
if (user) {
return { user, project };
}
} else if (project.teamId) {
const teamMembers = await this.teamDB.findMembersByTeam(project.teamId || "");
if (teamMembers.some((t) => t.userId === webhookInstaller.id)) {
return { user: webhookInstaller, project };
}
for (const teamMember of teamMembers) {
const user = await this.userDB.findUserById(teamMember.userId);
if (user && user.identities.some((i) => i.authProviderId === "Public-Bitbucket")) {
return { user, project };
}
}
}
}
return { user: webhookInstaller };
}

protected createContextUrl(event: PushEventPayload): string {
const projectBrowseUrl = event.repository.links.self[0].href;
const branchName = event.changes[0].ref.displayId;
const contextUrl = `${projectBrowseUrl}?at=${encodeURIComponent(branchName)}`;
return contextUrl;
}

get router(): express.Router {
return this._router;
}
}

interface PushEventPayload {
eventKey: "repo:refs_changed" | string;
date: string;
actor: {
name: string;
emailAddress: string;
id: number;
displayName: string;
slug: string;
type: "NORMAL" | string;
};
repository: {
slug: string;
id: number;
name: string;
project: {
key: string;
id: number;
name: string;
public: boolean;
type: "NORMAL" | "PERSONAL";
};
links: {
clone: {
href: string;
name: string;
}[];
self: {
href: string;
}[];
};
public: boolean;
};
changes: {
ref: {
id: string;
displayId: string;
type: "BRANCH" | string;
};
refId: string;
fromHash: string;
toHash: string;
type: "UPDATE" | string;
}[];
}
namespace PushEventPayload {
export function is(payload: any): payload is PushEventPayload {
return typeof payload === "object" && "eventKey" in payload && payload["eventKey"] === "repo:refs_changed";
}
}
Loading