Skip to content

Commit 76b51bc

Browse files
AlexTugarevroboquat
authored andcommitted
[bitbucket-server] support for projects and prebuilds
1 parent 9526f87 commit 76b51bc

26 files changed

+1672
-232
lines changed

components/dashboard/src/projects/NewProject.tsx

+24-13
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,26 @@ export default function NewProject() {
4343
const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]);
4444

4545
useEffect(() => {
46-
if (user && selectedProviderHost === undefined) {
47-
if (user.identities.find((i) => i.authProviderId === "Public-GitLab")) {
48-
setSelectedProviderHost("gitlab.com");
49-
} else if (user.identities.find((i) => i.authProviderId === "Public-GitHub")) {
50-
setSelectedProviderHost("github.com");
51-
} else if (user.identities.find((i) => i.authProviderId === "Public-Bitbucket")) {
52-
setSelectedProviderHost("bitbucket.org");
46+
(async () => {
47+
setAuthProviders(await getGitpodService().server.getAuthProviders());
48+
})();
49+
}, []);
50+
51+
useEffect(() => {
52+
if (user && authProviders && selectedProviderHost === undefined) {
53+
for (let i = user.identities.length - 1; i >= 0; i--) {
54+
const candidate = user.identities[i];
55+
if (candidate) {
56+
const authProvider = authProviders.find((ap) => ap.authProviderId === candidate.authProviderId);
57+
const host = authProvider?.host;
58+
if (host) {
59+
setSelectedProviderHost(host);
60+
break;
61+
}
62+
}
5363
}
54-
(async () => {
55-
setAuthProviders(await getGitpodService().server.getAuthProviders());
56-
})();
5764
}
58-
}, [user]);
65+
}, [user, authProviders]);
5966

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

654661
const filteredProviders = () =>
655662
props.authProviders.filter(
656-
(p) => p.authProviderType === "GitHub" || p.host === "bitbucket.org" || p.authProviderType === "GitLab",
663+
(p) =>
664+
p.authProviderType === "GitHub" ||
665+
p.host === "bitbucket.org" ||
666+
p.authProviderType === "GitLab" ||
667+
p.authProviderType === "BitbucketServer",
657668
);
658669

659670
return (

components/gitpod-protocol/src/gitpod-service.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ export interface ProviderRepository {
307307
account: string;
308308
accountAvatarUrl: string;
309309
cloneUrl: string;
310-
updatedAt: string;
310+
updatedAt?: string;
311311
installationId?: number;
312312
installationUpdatedAt?: string;
313313

components/gitpod-protocol/src/protocol.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,8 @@ export interface Repository {
10151015
owner: string;
10161016
name: string;
10171017
cloneUrl: string;
1018+
/* Optional kind to differentiate between repositories of orgs/groups/projects and personal repos. */
1019+
repoKind?: string;
10181020
description?: string;
10191021
avatarUrl?: string;
10201022
webUrl?: string;

components/server/ee/src/auth/host-container-mapping.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { HostContainerMapping } from "../../../src/auth/host-container-mapping";
99
import { gitlabContainerModuleEE } from "../gitlab/container-module";
1010
import { bitbucketContainerModuleEE } from "../bitbucket/container-module";
1111
import { gitHubContainerModuleEE } from "../github/container-module";
12+
import { bitbucketServerContainerModuleEE } from "../bitbucket-server/container-module";
1213

1314
@injectable()
1415
export class HostContainerMappingEE extends HostContainerMapping {
@@ -20,9 +21,8 @@ export class HostContainerMappingEE extends HostContainerMapping {
2021
return (modules || []).concat([gitlabContainerModuleEE]);
2122
case "Bitbucket":
2223
return (modules || []).concat([bitbucketContainerModuleEE]);
23-
// case "BitbucketServer":
24-
// FIXME
25-
// return (modules || []).concat([bitbucketContainerModuleEE]);
24+
case "BitbucketServer":
25+
return (modules || []).concat([bitbucketServerContainerModuleEE]);
2626
case "GitHub":
2727
return (modules || []).concat([gitHubContainerModuleEE]);
2828
default:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Copyright (c) 2020 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { ContainerModule } from "inversify";
8+
import { RepositoryService } from "../../../src/repohost/repo-service";
9+
import { BitbucketServerService } from "../prebuilds/bitbucket-server-service";
10+
11+
export const bitbucketServerContainerModuleEE = new ContainerModule((_bind, _unbind, _isBound, rebind) => {
12+
rebind(RepositoryService).to(BitbucketServerService).inSingletonScope();
13+
});

components/server/ee/src/container-module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { Config } from "../../src/config";
5858
import { SnapshotService } from "./workspace/snapshot-service";
5959
import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support";
6060
import { UserCounter } from "./user/user-counter";
61+
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
6162

6263
export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
6364
rebind(Server).to(ServerEE).inSingletonScope();
@@ -77,6 +78,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
7778
bind(BitbucketApp).toSelf().inSingletonScope();
7879
bind(BitbucketAppSupport).toSelf().inSingletonScope();
7980
bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
81+
bind(BitbucketServerApp).toSelf().inSingletonScope();
8082

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

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import * as express from "express";
8+
import { postConstruct, injectable, inject } from "inversify";
9+
import { ProjectDB, TeamDB, UserDB } from "@gitpod/gitpod-db/lib";
10+
import { PrebuildManager } from "../prebuilds/prebuild-manager";
11+
import { TokenService } from "../../../src/user/token-service";
12+
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
13+
import { CommitContext, CommitInfo, Project, StartPrebuildResult, User } from "@gitpod/gitpod-protocol";
14+
import { RepoURL } from "../../../src/repohost";
15+
import { HostContextProvider } from "../../../src/auth/host-context-provider";
16+
import { ContextParser } from "../../../src/workspace/context-parser-service";
17+
18+
@injectable()
19+
export class BitbucketServerApp {
20+
@inject(UserDB) protected readonly userDB: UserDB;
21+
@inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager;
22+
@inject(TokenService) protected readonly tokenService: TokenService;
23+
@inject(ProjectDB) protected readonly projectDB: ProjectDB;
24+
@inject(TeamDB) protected readonly teamDB: TeamDB;
25+
@inject(ContextParser) protected readonly contextParser: ContextParser;
26+
@inject(HostContextProvider) protected readonly hostCtxProvider: HostContextProvider;
27+
28+
protected _router = express.Router();
29+
public static path = "/apps/bitbucketserver/";
30+
31+
@postConstruct()
32+
protected init() {
33+
this._router.post("/", async (req, res) => {
34+
try {
35+
const payload = req.body;
36+
if (PushEventPayload.is(req.body)) {
37+
const span = TraceContext.startSpan("BitbucketApp.handleEvent", {});
38+
let queryToken = req.query["token"] as string;
39+
if (typeof queryToken === "string") {
40+
queryToken = decodeURIComponent(queryToken);
41+
}
42+
const user = await this.findUser({ span }, queryToken);
43+
if (!user) {
44+
// If the webhook installer is no longer found in Gitpod's DB
45+
// we should send a UNAUTHORIZED signal.
46+
res.statusCode = 401;
47+
res.send();
48+
return;
49+
}
50+
await this.handlePushHook({ span }, user, payload);
51+
} else {
52+
console.warn(`Ignoring unsupported BBS event.`, { headers: req.headers });
53+
}
54+
} catch (err) {
55+
console.error(`Couldn't handle request.`, err, { headers: req.headers, reqBody: req.body });
56+
} finally {
57+
// we always respond with OK, when we received a valid event.
58+
res.sendStatus(200);
59+
}
60+
});
61+
}
62+
63+
protected async findUser(ctx: TraceContext, secretToken: string): Promise<User> {
64+
const span = TraceContext.startSpan("BitbucketApp.findUser", ctx);
65+
try {
66+
span.setTag("secret-token", secretToken);
67+
const [userid, tokenValue] = secretToken.split("|");
68+
const user = await this.userDB.findUserById(userid);
69+
if (!user) {
70+
throw new Error("No user found for " + secretToken + " found.");
71+
} else if (!!user.blocked) {
72+
throw new Error(`Blocked user ${user.id} tried to start prebuild.`);
73+
}
74+
const identity = user.identities.find((i) => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID);
75+
if (!identity) {
76+
throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`);
77+
}
78+
const tokens = await this.userDB.findTokensForIdentity(identity);
79+
const token = tokens.find((t) => t.token.value === tokenValue);
80+
if (!token) {
81+
throw new Error(`User ${user.id} has no token with given value.`);
82+
}
83+
return user;
84+
} finally {
85+
span.finish();
86+
}
87+
}
88+
89+
protected async handlePushHook(
90+
ctx: TraceContext,
91+
user: User,
92+
event: PushEventPayload,
93+
): Promise<StartPrebuildResult | undefined> {
94+
const span = TraceContext.startSpan("Bitbucket.handlePushHook", ctx);
95+
try {
96+
const contextUrl = this.createContextUrl(event);
97+
span.setTag("contextUrl", contextUrl);
98+
const context = await this.contextParser.handle({ span }, user, contextUrl);
99+
if (!CommitContext.is(context)) {
100+
throw new Error("CommitContext exprected.");
101+
}
102+
const cloneUrl = context.repository.cloneUrl;
103+
const commit = context.revision;
104+
const projectAndOwner = await this.findProjectAndOwner(cloneUrl, user);
105+
const config = await this.prebuildManager.fetchConfig({ span }, user, context);
106+
if (!this.prebuildManager.shouldPrebuild(config)) {
107+
console.log("Bitbucket push event: No config. No prebuild.");
108+
return undefined;
109+
}
110+
111+
console.debug("Bitbucket Server push event: Starting prebuild.", { contextUrl });
112+
113+
const commitInfo = await this.getCommitInfo(user, cloneUrl, commit);
114+
115+
const ws = await this.prebuildManager.startPrebuild(
116+
{ span },
117+
{
118+
user: projectAndOwner.user,
119+
project: projectAndOwner?.project,
120+
context,
121+
commitInfo,
122+
},
123+
);
124+
return ws;
125+
} finally {
126+
span.finish();
127+
}
128+
}
129+
130+
private async getCommitInfo(user: User, repoURL: string, commitSHA: string) {
131+
const parsedRepo = RepoURL.parseRepoUrl(repoURL)!;
132+
const hostCtx = this.hostCtxProvider.get(parsedRepo.host);
133+
let commitInfo: CommitInfo | undefined;
134+
if (hostCtx?.services?.repositoryProvider) {
135+
commitInfo = await hostCtx?.services?.repositoryProvider.getCommitInfo(
136+
user,
137+
parsedRepo.owner,
138+
parsedRepo.repo,
139+
commitSHA,
140+
);
141+
}
142+
return commitInfo;
143+
}
144+
145+
/**
146+
* Finds the relevant user account and project to the provided webhook event information.
147+
*
148+
* First of all it tries to find the project for the given `cloneURL`, then it tries to
149+
* find the installer, which is also supposed to be a team member. As a fallback, it
150+
* looks for a team member which also has a bitbucket.org connection.
151+
*
152+
* @param cloneURL of the webhook event
153+
* @param webhookInstaller the user account known from the webhook installation
154+
* @returns a promise which resolves to a user account and an optional project.
155+
*/
156+
protected async findProjectAndOwner(
157+
cloneURL: string,
158+
webhookInstaller: User,
159+
): Promise<{ user: User; project?: Project }> {
160+
const project = await this.projectDB.findProjectByCloneUrl(cloneURL);
161+
if (project) {
162+
if (project.userId) {
163+
const user = await this.userDB.findUserById(project.userId);
164+
if (user) {
165+
return { user, project };
166+
}
167+
} else if (project.teamId) {
168+
const teamMembers = await this.teamDB.findMembersByTeam(project.teamId || "");
169+
if (teamMembers.some((t) => t.userId === webhookInstaller.id)) {
170+
return { user: webhookInstaller, project };
171+
}
172+
for (const teamMember of teamMembers) {
173+
const user = await this.userDB.findUserById(teamMember.userId);
174+
if (user && user.identities.some((i) => i.authProviderId === "Public-Bitbucket")) {
175+
return { user, project };
176+
}
177+
}
178+
}
179+
}
180+
return { user: webhookInstaller };
181+
}
182+
183+
protected createContextUrl(event: PushEventPayload): string {
184+
const projectBrowseUrl = event.repository.links.self[0].href;
185+
const branchName = event.changes[0].ref.displayId;
186+
const contextUrl = `${projectBrowseUrl}?at=${encodeURIComponent(branchName)}`;
187+
return contextUrl;
188+
}
189+
190+
get router(): express.Router {
191+
return this._router;
192+
}
193+
}
194+
195+
interface PushEventPayload {
196+
eventKey: "repo:refs_changed" | string;
197+
date: string;
198+
actor: {
199+
name: string;
200+
emailAddress: string;
201+
id: number;
202+
displayName: string;
203+
slug: string;
204+
type: "NORMAL" | string;
205+
};
206+
repository: {
207+
slug: string;
208+
id: number;
209+
name: string;
210+
project: {
211+
key: string;
212+
id: number;
213+
name: string;
214+
public: boolean;
215+
type: "NORMAL" | "PERSONAL";
216+
};
217+
links: {
218+
clone: {
219+
href: string;
220+
name: string;
221+
}[];
222+
self: {
223+
href: string;
224+
}[];
225+
};
226+
public: boolean;
227+
};
228+
changes: {
229+
ref: {
230+
id: string;
231+
displayId: string;
232+
type: "BRANCH" | string;
233+
};
234+
refId: string;
235+
fromHash: string;
236+
toHash: string;
237+
type: "UPDATE" | string;
238+
}[];
239+
}
240+
namespace PushEventPayload {
241+
export function is(payload: any): payload is PushEventPayload {
242+
return typeof payload === "object" && "eventKey" in payload && payload["eventKey"] === "repo:refs_changed";
243+
}
244+
}

0 commit comments

Comments
 (0)