Skip to content

Commit

Permalink
[bitbucket-server] support for projects and prebuilds
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexTugarev authored and roboquat committed Apr 4, 2022
1 parent 9526f87 commit 76b51bc
Show file tree
Hide file tree
Showing 26 changed files with 1,672 additions and 232 deletions.
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

0 comments on commit 76b51bc

Please sign in to comment.