diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx index fbf1d9fc0c6894..1e76cd2bd1ead0 100644 --- a/components/dashboard/src/projects/NewProject.tsx +++ b/components/dashboard/src/projects/NewProject.tsx @@ -43,19 +43,26 @@ export default function NewProject() { const [authProviders, setAuthProviders] = useState([]); 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); @@ -385,7 +392,7 @@ export default function NewProject() { > {toSimpleName(r.name)} -

Updated {moment(r.updatedAt).fromNow()}

+ {r.updatedAt &&

Updated {moment(r.updatedAt).fromNow()}

}
@@ -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 ( diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 727fd53beed4fa..af280e8fc5938b 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -307,7 +307,7 @@ export interface ProviderRepository { account: string; accountAvatarUrl: string; cloneUrl: string; - updatedAt: string; + updatedAt?: string; installationId?: number; installationUpdatedAt?: string; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index cdef49160e3afe..7d81090cd934b1 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -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; diff --git a/components/server/ee/src/auth/host-container-mapping.ts b/components/server/ee/src/auth/host-container-mapping.ts index bbfa4e39d9dc98..e3770223618899 100644 --- a/components/server/ee/src/auth/host-container-mapping.ts +++ b/components/server/ee/src/auth/host-container-mapping.ts @@ -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 { @@ -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: diff --git a/components/server/ee/src/bitbucket-server/container-module.ts b/components/server/ee/src/bitbucket-server/container-module.ts new file mode 100644 index 00000000000000..850d7dcf81b3c3 --- /dev/null +++ b/components/server/ee/src/bitbucket-server/container-module.ts @@ -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(); +}); diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index a1a26bb7012427..43c1a884c71ae3 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -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(); @@ -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(); diff --git a/components/server/ee/src/prebuilds/bitbucket-server-app.ts b/components/server/ee/src/prebuilds/bitbucket-server-app.ts new file mode 100644 index 00000000000000..bbf7d7b8797ce3 --- /dev/null +++ b/components/server/ee/src/prebuilds/bitbucket-server-app.ts @@ -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 { + 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 { + 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"; + } +} diff --git a/components/server/ee/src/prebuilds/bitbucket-server-service.spec.ts b/components/server/ee/src/prebuilds/bitbucket-server-service.spec.ts new file mode 100644 index 00000000000000..1ea75601843730 --- /dev/null +++ b/components/server/ee/src/prebuilds/bitbucket-server-service.spec.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { User } from "@gitpod/gitpod-protocol"; +import { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if"; +import { Container, ContainerModule } from "inversify"; +import { retries, suite, test, timeout } from "mocha-typescript"; +import { AuthProviderParams } from "../../../src/auth/auth-provider"; +import { HostContextProvider } from "../../../src/auth/host-context-provider"; +import { BitbucketServerApi } from "../../../src/bitbucket-server/bitbucket-server-api"; +import { BitbucketServerContextParser } from "../../../src/bitbucket-server/bitbucket-server-context-parser"; +import { BitbucketServerTokenHelper } from "../../../src/bitbucket-server/bitbucket-server-token-handler"; +import { TokenProvider } from "../../../src/user/token-provider"; +import { BitbucketServerService } from "./bitbucket-server-service"; +import { expect } from "chai"; +import { Config } from "../../../src/config"; +import { TokenService } from "../../../src/user/token-service"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; + +@suite(timeout(10000), retries(1), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_BITBUCKET_SERVER")) +class TestBitbucketServerService { + protected service: BitbucketServerService; + protected user: User; + + static readonly AUTH_HOST_CONFIG: Partial = { + id: "MyBitbucketServer", + type: "BitbucketServer", + verified: true, + description: "", + icon: "", + host: "bitbucket.gitpod-self-hosted.com", + oauth: { + callBackUrl: "", + clientId: "not-used", + clientSecret: "", + tokenUrl: "", + scope: "", + authorizationUrl: "", + }, + }; + + public before() { + const container = new Container(); + container.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(BitbucketServerService).toSelf().inSingletonScope(); + bind(BitbucketServerContextParser).toSelf().inSingletonScope(); + bind(AuthProviderParams).toConstantValue(TestBitbucketServerService.AUTH_HOST_CONFIG); + bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + bind(TokenService).toConstantValue({ + createGitpodToken: async () => ({ token: { value: "foobar123-token" } }), + } as any); + bind(Config).toConstantValue({ + hostUrl: new GitpodHostUrl(), + }); + bind(TokenProvider).toConstantValue({ + getTokenForHost: async () => { + return { + value: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"] || "undefined", + scopes: [], + }; + }, + getFreshPortAuthenticationToken: undefined as any, + }); + bind(BitbucketServerApi).toSelf().inSingletonScope(); + bind(HostContextProvider).toConstantValue({ + get: (hostname: string) => { + authProvider: { + ("BBS"); + } + }, + }); + }), + ); + this.service = container.get(BitbucketServerService); + this.user = { + creationDate: "", + id: "user1", + identities: [ + { + authId: "user1", + authName: "AlexTugarev", + authProviderId: "MyBitbucketServer", + }, + ], + }; + } + + @test async test_canInstallAutomatedPrebuilds_unauthorized() { + const result = await this.service.canInstallAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/users/jldec/repos/test-repo", + ); + expect(result).to.be.false; + } + + @test async test_canInstallAutomatedPrebuilds_in_project_ok() { + const result = await this.service.canInstallAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/projects/jldec/repos/jldec-repo-march-30", + ); + expect(result).to.be.true; + } + + @test async test_canInstallAutomatedPrebuilds_ok() { + const result = await this.service.canInstallAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + ); + expect(result).to.be.true; + } + + @test async test_canInstallAutomatedPrebuilds_users_project_ok() { + const result = await this.service.canInstallAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/scm/~alextugarev/yolo.git", + ); + expect(result).to.be.true; + } + + @test async test_installAutomatedPrebuilds_ok() { + try { + await this.service.installAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + ); + } catch (error) { + expect.fail(error); + } + } + + @test async test_installAutomatedPrebuilds_unauthorized() { + try { + await this.service.installAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/users/jldec/repos/test-repo", + ); + expect.fail("should have failed"); + } catch (error) {} + } + + @test async test_installAutomatedPrebuilds_in_project_ok() { + try { + await this.service.installAutomatedPrebuilds( + this.user, + "https://bitbucket.gitpod-self-hosted.com/projects/jldec/repos/jldec-repo-march-30", + ); + } catch (error) { + expect.fail(error); + } + } +} + +module.exports = new TestBitbucketServerService(); diff --git a/components/server/ee/src/prebuilds/bitbucket-server-service.ts b/components/server/ee/src/prebuilds/bitbucket-server-service.ts new file mode 100644 index 00000000000000..eb22c5c59510cb --- /dev/null +++ b/components/server/ee/src/prebuilds/bitbucket-server-service.ts @@ -0,0 +1,135 @@ +/** + * 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 { RepositoryService } from "../../../src/repohost/repo-service"; +import { ProviderRepository, User } from "@gitpod/gitpod-protocol"; +import { inject, injectable } from "inversify"; +import { BitbucketServerApi } from "../../../src/bitbucket-server/bitbucket-server-api"; +import { AuthProviderParams } from "../../../src/auth/auth-provider"; +import { BitbucketServerContextParser } from "../../../src/bitbucket-server/bitbucket-server-context-parser"; +import { Config } from "../../../src/config"; +import { TokenService } from "../../../src/user/token-service"; +import { BitbucketServerApp } from "./bitbucket-server-app"; + +@injectable() +export class BitbucketServerService extends RepositoryService { + static PREBUILD_TOKEN_SCOPE = "prebuilds"; + + @inject(BitbucketServerApi) protected api: BitbucketServerApi; + @inject(Config) protected readonly config: Config; + @inject(AuthProviderParams) protected authProviderConfig: AuthProviderParams; + @inject(TokenService) protected tokenService: TokenService; + @inject(BitbucketServerContextParser) protected contextParser: BitbucketServerContextParser; + + async getRepositoriesForAutomatedPrebuilds(user: User): Promise { + const repos = await this.api.getRepos(user, { limit: 100, permission: "REPO_ADMIN" }); + return (repos.values || []).map((r) => { + const cloneUrl = r.links.clone.find((u) => u.name === "http")?.href!; + // const webUrl = r.links?.self[0]?.href?.replace("/browse", ""); + const accountAvatarUrl = this.api.getAvatarUrl(r.project.key); + return { + name: r.name, + cloneUrl, + account: r.project.key, + accountAvatarUrl, + // updatedAt: TODO(at): this isn't provided directly + }; + }); + } + + async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise { + const { host, repoKind, owner, repoName } = await this.contextParser.parseURL(user, cloneUrl); + if (host !== this.authProviderConfig.host) { + return false; + } + + const identity = user.identities.find((i) => i.authProviderId === this.authProviderConfig.id); + if (!identity) { + console.error( + `Unexpected call of canInstallAutomatedPrebuilds. Not authorized with ${this.authProviderConfig.host}.`, + ); + return false; + } + + try { + await this.api.getWebhooks(user, { repoKind, repositorySlug: repoName, owner }); + // reading webhooks to check if admin scope is provided + } catch (error) { + return false; + } + + if (repoKind === "users") { + const ownProfile = await this.api.getUserProfile(user, identity.authName); + if (owner === ownProfile.slug) { + return true; + } + } + + let permission = await this.api.getPermission(user, { username: identity.authName, repoKind, owner, repoName }); + if (!permission && repoKind === "projects") { + permission = await this.api.getPermission(user, { username: identity.authName, repoKind, owner }); + } + + if (this.hasPermissionToCreateWebhooks(permission)) { + return true; + } + + console.debug( + `User is not allowed to install webhooks.\n${JSON.stringify(identity)}\n${JSON.stringify(permission)}`, + ); + return false; + } + + protected hasPermissionToCreateWebhooks(permission: string | undefined) { + return permission && ["REPO_ADMIN", "PROJECT_ADMIN"].indexOf(permission) !== -1; + } + + async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise { + const { owner, repoName, repoKind } = await this.contextParser.parseURL(user, cloneUrl); + + const existing = await this.api.getWebhooks(user, { + repoKind, + repositorySlug: repoName, + owner, + }); + const hookUrl = this.getHookUrl(); + if (existing.values && existing.values.some((hook) => hook.url && hook.url.indexOf(hookUrl) !== -1)) { + console.log(`BBS webhook already installed on ${cloneUrl}`); + return; + } + const tokenEntry = await this.tokenService.createGitpodToken( + user, + BitbucketServerService.PREBUILD_TOKEN_SCOPE, + cloneUrl, + ); + try { + await this.api.setWebhook( + user, + { repoKind, repositorySlug: repoName, owner }, + { + name: `Gitpod Prebuilds for ${this.config.hostUrl}.`, + active: true, + configuration: { + secret: "foobar123-secret", + }, + url: hookUrl + `?token=${encodeURIComponent(user.id + "|" + tokenEntry.token.value)}`, + events: ["repo:refs_changed"], + }, + ); + console.log("Installed Bitbucket Server Webhook for " + cloneUrl); + } catch (error) { + console.error(`Couldn't install Bitbucket Server Webhook for ${cloneUrl}`, error); + } + } + + protected getHookUrl() { + return this.config.hostUrl + .with({ + pathname: BitbucketServerApp.path, + }) + .toString(); + } +} diff --git a/components/server/ee/src/prebuilds/bitbucket-service.ts b/components/server/ee/src/prebuilds/bitbucket-service.ts index 261a3ffabb1896..07a98e664f5bf1 100644 --- a/components/server/ee/src/prebuilds/bitbucket-service.ts +++ b/components/server/ee/src/prebuilds/bitbucket-service.ts @@ -25,13 +25,12 @@ export class BitbucketService extends RepositoryService { @inject(BitbucketContextParser) protected bitbucketContextParser: BitbucketContextParser; async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise { - const { host } = await this.bitbucketContextParser.parseURL(user, cloneUrl); + const { host, owner, repoName } = await this.bitbucketContextParser.parseURL(user, cloneUrl); 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}"`, diff --git a/components/server/ee/src/server.ts b/components/server/ee/src/server.ts index 8434a9c43e53b9..d745b64c22755e 100644 --- a/components/server/ee/src/server.ts +++ b/components/server/ee/src/server.ts @@ -14,11 +14,13 @@ import { BitbucketApp } from "./prebuilds/bitbucket-app"; import { GithubApp } from "./prebuilds/github-app"; import { SnapshotService } from "./workspace/snapshot-service"; import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app"; +import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app"; export class ServerEE extends Server { @inject(GithubApp) protected readonly githubApp: GithubApp; @inject(GitLabApp) protected readonly gitLabApp: GitLabApp; @inject(BitbucketApp) protected readonly bitbucketApp: BitbucketApp; + @inject(BitbucketServerApp) protected readonly bitbucketServerApp: BitbucketServerApp; @inject(SnapshotService) protected readonly snapshotService: SnapshotService; @inject(GitHubEnterpriseApp) protected readonly gitHubEnterpriseApp: GitHubEnterpriseApp; @@ -48,5 +50,8 @@ export class ServerEE extends Se log.info("Registered GitHub EnterpriseApp app at " + GitHubEnterpriseApp.path); app.use(GitHubEnterpriseApp.path, this.gitHubEnterpriseApp.router); + + log.info("Registered Bitbucket Server app at " + BitbucketServerApp.path); + app.use(BitbucketServerApp.path, this.bitbucketServerApp.router); } } diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index bb02a8e5c7509f..8303ac344b12d7 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -1785,6 +1785,13 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } else if (providerHost === "bitbucket.org" && provider) { repositories.push(...(await this.bitbucketAppSupport.getProviderRepositoriesForUser({ user, provider }))); + } else if (provider?.authProviderType === "BitbucketServer") { + const hostContext = this.hostContextProvider.get(providerHost); + if (hostContext?.services) { + repositories.push( + ...(await hostContext.services.repositoryService.getRepositoriesForAutomatedPrebuilds(user)), + ); + } } 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 249db039f63c4d..5b487a5667bff8 100644 --- a/components/server/package.json +++ b/components/server/package.json @@ -27,7 +27,6 @@ "/dist" ], "dependencies": { - "@atlassian/bitbucket-server": "^0.0.6", "@gitbeaker/node": "^25.6.0", "@gitpod/content-service": "0.1.5", "@gitpod/gitpod-db": "0.1.5", diff --git a/components/server/src/bitbucket-server/bitbucket-server-api.spec.ts b/components/server/src/bitbucket-server/bitbucket-server-api.spec.ts new file mode 100644 index 00000000000000..9a24e1005e4e7b --- /dev/null +++ b/components/server/src/bitbucket-server/bitbucket-server-api.spec.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { User } from "@gitpod/gitpod-protocol"; +import { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if"; +import { Container, ContainerModule } from "inversify"; +import { retries, suite, test, timeout } from "mocha-typescript"; +import { expect } from "chai"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import { AuthProviderParams } from "../auth/auth-provider"; +import { BitbucketServerContextParser } from "./bitbucket-server-context-parser"; +import { BitbucketServerTokenHelper } from "./bitbucket-server-token-handler"; +import { TokenService } from "../user/token-service"; +import { Config } from "../config"; +import { TokenProvider } from "../user/token-provider"; +import { BitbucketServerApi } from "./bitbucket-server-api"; +import { HostContextProvider } from "../auth/host-context-provider"; + +@suite(timeout(10000), retries(0), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_BITBUCKET_SERVER")) +class TestBitbucketServerApi { + protected api: BitbucketServerApi; + protected user: User; + + static readonly AUTH_HOST_CONFIG: Partial = { + id: "MyBitbucketServer", + type: "BitbucketServer", + verified: true, + host: "bitbucket.gitpod-self-hosted.com", + oauth: {} as any, + }; + + public before() { + const container = new Container(); + container.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(BitbucketServerApi).toSelf().inSingletonScope(); + bind(BitbucketServerContextParser).toSelf().inSingletonScope(); + bind(AuthProviderParams).toConstantValue(TestBitbucketServerApi.AUTH_HOST_CONFIG); + bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + bind(TokenService).toConstantValue({ + createGitpodToken: async () => ({ token: { value: "foobar123-token" } }), + } as any); + bind(Config).toConstantValue({ + hostUrl: new GitpodHostUrl(), + }); + bind(TokenProvider).toConstantValue({ + getTokenForHost: async () => { + return { + value: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"] || "undefined", + scopes: [], + }; + }, + getFreshPortAuthenticationToken: undefined as any, + }); + bind(HostContextProvider).toConstantValue({ + get: (hostname: string) => { + authProvider: { + ("BBS"); + } + }, + }); + }), + ); + this.api = container.get(BitbucketServerApi); + this.user = { + creationDate: "", + id: "user1", + identities: [ + { + authId: "user1", + authName: "AlexTugarev", + authProviderId: "MyBitbucketServer", + }, + ], + }; + } + + @test async test_currentUsername_ok() { + const result = await this.api.currentUsername(process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"]!); + expect(result).to.equal("AlexTugarev"); + } + + @test async test_getUserProfile_ok() { + const result = await this.api.getUserProfile(process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"]!, "AlexTugarev"); + expect(result).to.deep.include({ + id: 105, // Identity.authId + name: "AlexTugarev", // Identity.authName + slug: "alextugarev", // used in URLs + displayName: "Alex Tugarev", + }); + } +} + +module.exports = new TestBitbucketServerApi(); diff --git a/components/server/src/bitbucket-server/bitbucket-server-api.ts b/components/server/src/bitbucket-server/bitbucket-server-api.ts index 168f27c0803e26..289fc708a2e201 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-api.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-api.ts @@ -15,71 +15,310 @@ export class BitbucketServerApi { @inject(AuthProviderParams) protected readonly config: AuthProviderParams; @inject(BitbucketServerTokenHelper) protected readonly tokenHelper: BitbucketServerTokenHelper; - public async runQuery(user: User, urlPath: string): Promise { + public async runQuery( + userOrToken: User | string, + urlPath: string, + method: string = "GET", + body?: string, + ): Promise { + const token = + typeof userOrToken === "string" + ? userOrToken + : (await this.tokenHelper.getTokenWithScopes(userOrToken, [])).value; + const fullUrl = `${this.baseUrl}${urlPath}`; + let result: string = "OK"; + try { + const response = await fetch(fullUrl, { + timeout: 10000, + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body, + }); + + if (!response.ok) { + let json: object | undefined; + try { + json = await response.json(); + } catch { + // ignoring non-json responses and handling in general case bellow + } + if (BitbucketServer.ErrorResponse.is(json)) { + throw Object.assign(new Error(`${response.status} / ${json.errors[0]?.message}`), { + json, + }); + } + throw Object.assign(new Error(`${response.status} / ${response.statusText}`), { response }); + } + return (await response.json()) as T; + } catch (error) { + result = "error " + error?.message; + throw error; + } finally { + console.debug(`BitbucketServer GET ${fullUrl} - ${result}`); + } + } + + public async fetchContent(user: User, urlPath: string): Promise { const token = (await this.tokenHelper.getTokenWithScopes(user, [])).value; const fullUrl = `${this.baseUrl}${urlPath}`; - const response = await fetch(fullUrl, { - timeout: 10000, - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }); - if (!response.ok) { - throw Error(response.statusText); + let result: string = "OK"; + try { + const response = await fetch(fullUrl, { + timeout: 10000, + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw Error(`${response.status} / ${response.statusText}`); + } + return await response.text(); + } catch (error) { + result = "error " + error?.message; + throw error; + } finally { + console.debug(`BBS GET ${fullUrl} - ${result}`); + } + } + + public async currentUsername(accessToken: string): Promise { + const fullUrl = `https://${this.config.host}/plugins/servlet/applinks/whoami`; + let result: string = "OK"; + try { + const response = await fetch(fullUrl, { + timeout: 10000, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!response.ok) { + throw new Error(`${response.status} / ${response.statusText}`); + } + return response.text(); + } catch (error) { + result = error?.message; + console.error(`BBS GET ${fullUrl} - ${result}`); + throw error; + } + } + + getAvatarUrl(username: string) { + return `https://${this.config.host}/users/${username}/avatar.png`; + } + + async getUserProfile(userOrToken: User | string, username: string): Promise { + return this.runQuery(userOrToken, `/users/${username}`); + } + + async getProject(userOrToken: User | string, projectSlug: string): Promise { + return this.runQuery(userOrToken, `/projects/${projectSlug}`); + } + + async getPermission( + user: User, + params: { username: string; repoKind: BitbucketServer.RepoKind; owner: string; repoName?: string }, + ): Promise { + const { username, repoKind, owner, repoName } = params; + if (repoName) { + const repoPermissions = await this.runQuery>( + user, + `/${repoKind}/${owner}/repos/${repoName}/permissions/users`, + ); + const repoPermission = repoPermissions.values?.find((p) => p.user.name === username)?.permission; + if (repoPermission) { + return repoPermission; + } + } + if (repoKind === "projects") { + const projectPermissions = await this.runQuery>( + user, + `/${repoKind}/${owner}/permissions/users`, + ); + const projectPermission = projectPermissions.values?.find((p) => p.user.name === username)?.permission; + return projectPermission; } - const result = await response.json(); - return result as T; } protected get baseUrl(): string { return `https://${this.config.host}/rest/api/1.0`; } - getRepository( + async getRepository( user: User, - params: { kind: "projects" | "users"; userOrProject: string; repositorySlug: string }, + params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string }, ): Promise { return this.runQuery( user, - `/${params.kind}/${params.userOrProject}/repos/${params.repositorySlug}`, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}`, ); } - getCommits( + async getCommits( user: User, - params: { kind: "projects" | "users"; userOrProject: string; repositorySlug: string; q?: { limit: number } }, + params: { + repoKind: "projects" | "users" | string; + owner: string; + repositorySlug: string; + query?: { limit?: number; path?: string; shaOrRevision?: string }; + }, ): Promise> { + let q = ""; + if (params.query) { + const segments = []; + if (params.query.limit) { + segments.push(`limit=${params.query.limit}`); + } + if (params.query.path) { + segments.push(`path=${params.query.path}`); + } + if (params.query.shaOrRevision) { + segments.push(`until=${params.query.shaOrRevision}`); + } + if (segments.length > 0) { + q = `?${segments.join("&")}`; + } + } return this.runQuery>( user, - `/${params.kind}/${params.userOrProject}/repos/${params.repositorySlug}/commits`, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/commits${q}`, ); } - getDefaultBranch( + async getDefaultBranch( user: User, - params: { kind: "projects" | "users"; userOrProject: string; repositorySlug: string }, + params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string }, ): Promise { - //https://bitbucket.gitpod-self-hosted.com/rest/api/1.0/users/jldec/repos/test-repo/default-branch return this.runQuery( user, - `/${params.kind}/${params.userOrProject}/repos/${params.repositorySlug}/default-branch`, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/default-branch`, + ); + } + + getHtmlUrlForBranch(params: { + repoKind: "projects" | "users"; + owner: string; + repositorySlug: string; + branchName: string; + }): string { + return `https://${this.config.host}/${params.repoKind}/${params.owner}/repos/${ + params.repositorySlug + }/browse?at=${encodeURIComponent(`refs/heads/${params.branchName}`)}`; + } + + async getBranch( + user: User, + params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string; branchName: string }, + ): Promise { + const result = await this.runQuery>( + user, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/branches?details=true&filterText=${params.branchName}&boostMatches=true`, + ); + const first = result.values && result.values[0]; + if (first && first.displayId === params.branchName) { + first.latestCommitMetadata = + first.metadata["com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata"]; + first.htmlUrl = this.getHtmlUrlForBranch({ + repoKind: params.repoKind, + owner: params.owner, + repositorySlug: params.repositorySlug, + branchName: first.displayId, + }); + return first; + } + throw new Error(`Could not find branch "${params.branchName}."`); + } + + async getBranches( + user: User, + params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string }, + ): Promise { + const result = await this.runQuery>( + user, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/branches?details=true&orderBy=MODIFICATION&limit=1000`, + ); + const branches = result.values || []; + for (const branch of branches) { + branch.latestCommitMetadata = + branch.metadata["com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata"]; + branch.htmlUrl = this.getHtmlUrlForBranch({ + repoKind: params.repoKind, + owner: params.owner, + repositorySlug: params.repositorySlug, + branchName: branch.displayId, + }); + } + return branches; + } + + async getWebhooks( + user: User, + params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string }, + ): Promise> { + return this.runQuery>( + user, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/webhooks`, + ); + } + + setWebhook( + user: User, + params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string }, + webhook: BitbucketServer.WebhookParams, + ) { + const body = JSON.stringify(webhook); + return this.runQuery( + user, + `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/webhooks`, + "POST", + body, ); } + + async getRepos( + user: User, + query: { + permission?: "REPO_READ" | "REPO_WRITE" | "REPO_ADMIN"; + limit: number; + }, + ) { + let q = ""; + if (query) { + const segments = []; + if (query.permission) { + segments.push(`permission=${query.permission}`); + } + if (query.limit) { + segments.push(`limit=${query.limit}`); + } + if (segments.length > 0) { + q = `?${segments.join("&")}`; + } + } + return this.runQuery>(user, `/repos${q}`); + } } export namespace BitbucketServer { + export type RepoKind = "users" | "projects"; export interface Repository { id: number; slug: string; name: string; + description?: string; public: boolean; links: { clone: { href: string; name: string; }[]; + self: { + href: string; + }[]; }; project: Project; } @@ -100,6 +339,14 @@ export namespace BitbucketServer { isDefault: boolean; } + export interface BranchWithMeta extends Branch { + latestCommitMetadata: Commit; + htmlUrl: string; + metadata: { + "com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata": Commit; + }; + } + export interface User { name: string; emailAddress: string; @@ -115,12 +362,17 @@ export namespace BitbucketServer { }, ]; }; + avatarUrl?: string; } export interface Commit { id: string; displayId: string; author: BitbucketServer.User; + authorTimestamp: number; + commiter: BitbucketServer.User; + committerTimestamp: number; + message: string; } export interface Paginated { @@ -131,4 +383,45 @@ export namespace BitbucketServer { values?: T[]; [k: string]: any; } + + export interface Webhook { + id: number; + name: "test-webhook"; + createdDate: number; + updatedDate: number; + events: any; + configuration: any; + url: string; + active: boolean; + } + + export interface PermissionEntry { + user: User; + permission: string; + } + + export interface WebhookParams { + name: string; + events: string[]; + // "events": [ + // "repo:refs_changed", + // "repo:modified" + // ], + configuration: { + secret: string; + }; + url: string; + active: boolean; + } + + export interface ErrorResponse { + errors: { + message: string; + }[]; + } + export namespace ErrorResponse { + export function is(o: any): o is ErrorResponse { + return typeof o === "object" && "errors" in o; + } + } } diff --git a/components/server/src/bitbucket-server/bitbucket-server-auth-provider.ts b/components/server/src/bitbucket-server/bitbucket-server-auth-provider.ts index bfc990c641815c..2146ea25baa202 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-auth-provider.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-auth-provider.ts @@ -7,15 +7,16 @@ import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import * as express from "express"; -import { injectable } from "inversify"; -import fetch from "node-fetch"; +import { inject, injectable } from "inversify"; import { AuthUserSetup } from "../auth/auth-provider"; import { GenericAuthProvider } from "../auth/generic-auth-provider"; import { BitbucketServerOAuthScopes } from "./bitbucket-server-oauth-scopes"; -import * as BitbucketServer from "@atlassian/bitbucket-server"; +import { BitbucketServerApi } from "./bitbucket-server-api"; @injectable() export class BitbucketServerAuthProvider extends GenericAuthProvider { + @inject(BitbucketServerApi) protected readonly api: BitbucketServerApi; + get info(): AuthProviderInfo { return { ...this.defaultInfo(), @@ -54,41 +55,18 @@ export class BitbucketServerAuthProvider extends GenericAuthProvider { protected readAuthUserSetup = async (accessToken: string, _tokenResponse: object) => { try { - const fetchResult = await fetch(`https://${this.params.host}/plugins/servlet/applinks/whoami`, { - timeout: 10000, - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - if (!fetchResult.ok) { - throw new Error(fetchResult.statusText); - } - const username = await fetchResult.text(); - if (!username) { - throw new Error("username missing"); - } - - log.warn(`(${this.strategyName}) username ${username}`); - - const options = { - baseUrl: `https://${this.params.host}`, - }; - const client = new BitbucketServer(options); - - client.authenticate({ type: "token", token: accessToken }); - const result = await client.api.getUser({ userSlug: username }); - - const user = result.data; - - // TODO: check if user.active === true? - + const username = await this.api.currentUsername(accessToken); + const userProfile = await this.api.getUserProfile(accessToken, username); + const avatarUrl = await this.api.getAvatarUrl(username); return { authUser: { - authId: `${user.id!}`, - authName: user.slug!, - primaryEmail: user.emailAddress!, - name: user.displayName!, - // avatarUrl: user.links!.avatar!.href // TODO + // e.g. 105 + authId: `${userProfile.id!}`, + // HINT: userProfile.name is used to match permission in repo/webhook services + authName: userProfile.name, + primaryEmail: userProfile.emailAddress!, + name: userProfile.displayName!, + avatarUrl, }, currentScopes: BitbucketServerOAuthScopes.ALL, }; diff --git a/components/server/src/bitbucket-server/bitbucket-server-context-parser.spec.ts b/components/server/src/bitbucket-server/bitbucket-server-context-parser.spec.ts new file mode 100644 index 00000000000000..2e8a0fb8c0b5b1 --- /dev/null +++ b/components/server/src/bitbucket-server/bitbucket-server-context-parser.spec.ts @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { User } from "@gitpod/gitpod-protocol"; +import { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if"; +import { Container, ContainerModule } from "inversify"; +import { suite, test, timeout } from "mocha-typescript"; +import { expect } from "chai"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import { BitbucketServerFileProvider } from "./bitbucket-server-file-provider"; +import { AuthProviderParams } from "../auth/auth-provider"; +import { BitbucketServerContextParser } from "./bitbucket-server-context-parser"; +import { BitbucketServerTokenHelper } from "./bitbucket-server-token-handler"; +import { TokenService } from "../user/token-service"; +import { Config } from "../config"; +import { TokenProvider } from "../user/token-provider"; +import { BitbucketServerApi } from "./bitbucket-server-api"; +import { HostContextProvider } from "../auth/host-context-provider"; + +@suite(timeout(10000), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_BITBUCKET_SERVER")) +class TestBitbucketServerContextParser { + protected parser: BitbucketServerContextParser; + protected user: User; + + static readonly AUTH_HOST_CONFIG: Partial = { + id: "MyBitbucketServer", + type: "BitbucketServer", + host: "bitbucket.gitpod-self-hosted.com", + }; + + public before() { + const container = new Container(); + container.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(BitbucketServerFileProvider).toSelf().inSingletonScope(); + bind(BitbucketServerContextParser).toSelf().inSingletonScope(); + bind(AuthProviderParams).toConstantValue(TestBitbucketServerContextParser.AUTH_HOST_CONFIG); + bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + bind(TokenService).toConstantValue({ + createGitpodToken: async () => ({ token: { value: "foobar123-token" } }), + } as any); + bind(Config).toConstantValue({ + hostUrl: new GitpodHostUrl(), + }); + bind(TokenProvider).toConstantValue({ + getTokenForHost: async () => { + return { + value: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"] || "undefined", + scopes: [], + }; + }, + getFreshPortAuthenticationToken: undefined as any, + }); + bind(BitbucketServerApi).toSelf().inSingletonScope(); + bind(HostContextProvider).toConstantValue({ + get: (hostname: string) => { + authProvider: { + ("BBS"); + } + }, + }); + }), + ); + this.parser = container.get(BitbucketServerContextParser); + this.user = { + creationDate: "", + id: "user1", + identities: [ + { + authId: "user1", + authName: "AlexTugarev", + authProviderId: "MyBitbucketServer", + }, + ], + }; + } + + @test async test_tree_context_01() { + const result = await this.parser.handle( + {}, + this.user, + "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + ); + + expect(result).to.deep.include({ + ref: "master", + refType: "branch", + revision: "535924584468074ec5dcbe935f4e68fbc3f0cb2d", + path: "", + isFile: false, + repository: { + host: "bitbucket.gitpod-self-hosted.com", + owner: "FOO", + name: "repo123", + cloneUrl: "https://bitbucket.gitpod-self-hosted.com/scm/foo/repo123.git", + webUrl: "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + defaultBranch: "master", + private: true, + repoKind: "projects", + }, + title: "FOO/repo123 - master", + }); + } + + @test async test_tree_context_02() { + const result = await this.parser.handle( + {}, + this.user, + "https://bitbucket.gitpod-self-hosted.com/scm/foo/repo123.git", + ); + + expect(result).to.deep.include({ + ref: "master", + refType: "branch", + revision: "535924584468074ec5dcbe935f4e68fbc3f0cb2d", + path: "", + isFile: false, + repository: { + host: "bitbucket.gitpod-self-hosted.com", + owner: "FOO", + name: "repo123", + cloneUrl: "https://bitbucket.gitpod-self-hosted.com/scm/foo/repo123.git", + webUrl: "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + defaultBranch: "master", + private: true, + repoKind: "projects", + }, + title: "foo/repo123 - master", + }); + } + + @test async test_tree_context_03() { + const result = await this.parser.handle( + {}, + this.user, + "https://bitbucket.gitpod-self-hosted.com/scm/~alextugarev/tada.git", + ); + + expect(result).to.deep.include({ + ref: "main", + refType: "branch", + revision: "a15d7d15adee54d0afdbe88148c8e587e8fb609d", + path: "", + isFile: false, + repository: { + host: "bitbucket.gitpod-self-hosted.com", + owner: "alextugarev", + name: "tada", + cloneUrl: "https://bitbucket.gitpod-self-hosted.com/scm/~alextugarev/tada.git", + webUrl: "https://bitbucket.gitpod-self-hosted.com/users/alextugarev/repos/tada", + defaultBranch: "main", + private: true, + repoKind: "users", + }, + title: "alextugarev/tada - main", + }); + } +} + +module.exports = new TestBitbucketServerContextParser(); diff --git a/components/server/src/bitbucket-server/bitbucket-server-context-parser.ts b/components/server/src/bitbucket-server/bitbucket-server-context-parser.ts index 77aaa99afcbbe6..8bd2daa6f5db7a 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-context-parser.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-context-parser.ts @@ -25,12 +25,18 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen const span = TraceContext.startSpan("BitbucketServerContextParser.handle", ctx); try { - const { resourceKind, host, owner, repoName /*moreSegments, searchParams*/ } = await this.parseURL( + const more: Partial = {}; + const { repoKind, host, owner, repoName, /*moreSegments*/ searchParams } = await this.parseURL( user, contextUrl, ); - return await this.handleNavigatorContext(ctx, user, resourceKind, host, owner, repoName); + if (searchParams.has("at")) { + more.ref = decodeURIComponent(searchParams.get("at")!); + more.refType = "branch"; + } + + return await this.handleNavigatorContext(ctx, user, repoKind, host, owner, repoName, more); } catch (e) { span.addTags({ contextUrl }).log({ error: e }); log.error({ userId: user.id }, "Error parsing Bitbucket context", e); @@ -40,7 +46,7 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen } } - public async parseURL(user: User, contextUrl: string): Promise<{ resourceKind: string } & URLParts> { + public async parseURL(user: User, contextUrl: string): Promise<{ repoKind: "projects" | "users" } & URLParts> { const url = new URL(contextUrl); const pathname = url.pathname.replace(/^\//, "").replace(/\/$/, ""); // pathname without leading and trailing slash const segments = pathname.split("/"); @@ -54,14 +60,31 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen segments.splice(0, lenghtOfRelativePath); } - const resourceKind = segments[0]; - const owner: string = segments[1]; - const repoName: string = segments[3]; - const moreSegmentsStart: number = 4; + let firstSegment = segments[0]; + let owner: string = segments[1]; + let repoKind: "users" | "projects"; + let repoName; + let moreSegmentsStart; + if (firstSegment === "scm") { + repoKind = "projects"; + if (owner && owner.startsWith("~")) { + repoKind = "users"; + owner = owner.substring(1); + } + repoName = segments[2]; + moreSegmentsStart = 3; + } else if (firstSegment === "projects" || firstSegment === "users") { + repoKind = firstSegment; + repoName = segments[3]; + moreSegmentsStart = 4; + } else { + throw new Error("Unexpected repo kind: " + firstSegment); + } const endsWithRepoName = segments.length === moreSegmentsStart; + const searchParams = url.searchParams; return { - resourceKind, + repoKind, host, owner, repoName: this.parseRepoName(repoName, endsWithRepoName), @@ -83,7 +106,7 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen protected async handleNavigatorContext( ctx: TraceContext, user: User, - resourceKind: string, + repoKind: "projects" | "users", host: string, owner: string, repoName: string, @@ -91,20 +114,17 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen ): Promise { const span = TraceContext.startSpan("BitbucketServerContextParser.handleNavigatorContext", ctx); try { - if (resourceKind !== "users" && resourceKind !== "projects") { - throw new Error("Only /users/ and /projects/ resources are supported."); - } const repo = await this.api.getRepository(user, { - kind: resourceKind, - userOrProject: owner, + repoKind, + owner, repositorySlug: repoName, }); const defaultBranch = await this.api.getDefaultBranch(user, { - kind: resourceKind, - userOrProject: owner, + repoKind, + owner, repositorySlug: repoName, }); - const repository = await this.toRepository(user, host, repo, defaultBranch); + const repository = this.toRepository(host, repo, repoKind, defaultBranch); span.log({ "request.finished": "" }); if (!repo) { @@ -124,10 +144,10 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen if (!more.revision) { const tipCommitOnDefaultBranch = await this.api.getCommits(user, { - kind: resourceKind, - userOrProject: owner, + repoKind, + owner, repositorySlug: repoName, - q: { limit: 1 }, + query: { limit: 1 }, }); const commits = tipCommitOnDefaultBranch?.values || []; if (commits.length === 0) { @@ -137,15 +157,19 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen more.refType = undefined; } else { more.revision = commits[0].id; - more.refType = "revision"; + // more.refType = "revision"; } } - return { - ...more, + return { + isFile: false, + path: "", title: `${owner}/${repoName} - ${more.ref || more.revision}${more.path ? ":" + more.path : ""}`, + ref: more.ref, + refType: more.refType, + revision: more.revision, repository, - } as NavigatorContext; + }; } catch (e) { span.log({ error: e }); log.error({ userId: user.id }, "Error parsing Bitbucket navigator request context", e); @@ -155,25 +179,24 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen } } - protected async toRepository( - user: User, + protected toRepository( host: string, repo: BitbucketServer.Repository, + repoKind: string, defaultBranch: BitbucketServer.Branch, - ): Promise { - if (!repo) { - throw new Error("Unknown repository."); - } - + ): Repository { const owner = repo.project.owner ? repo.project.owner.slug : repo.project.key; const name = repo.name; const cloneUrl = repo.links.clone.find((u) => u.name === "http")?.href!; + const webUrl = repo.links?.self[0]?.href?.replace(/\/browse$/, ""); const result: Repository = { + webUrl, cloneUrl, host, name, owner, + repoKind, private: !repo.public, defaultBranch: defaultBranch.displayId || DEFAULT_BRANCH, }; diff --git a/components/server/src/bitbucket-server/bitbucket-server-file-provider.spec.ts b/components/server/src/bitbucket-server/bitbucket-server-file-provider.spec.ts new file mode 100644 index 00000000000000..415772fd3ce8f7 --- /dev/null +++ b/components/server/src/bitbucket-server/bitbucket-server-file-provider.spec.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { Repository, User } from "@gitpod/gitpod-protocol"; +import { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if"; +import { Container, ContainerModule } from "inversify"; +import { retries, suite, test, timeout } from "mocha-typescript"; +import { expect } from "chai"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import { BitbucketServerFileProvider } from "./bitbucket-server-file-provider"; +import { AuthProviderParams } from "../auth/auth-provider"; +import { BitbucketServerContextParser } from "./bitbucket-server-context-parser"; +import { BitbucketServerTokenHelper } from "./bitbucket-server-token-handler"; +import { TokenService } from "../user/token-service"; +import { Config } from "../config"; +import { TokenProvider } from "../user/token-provider"; +import { BitbucketServerApi } from "./bitbucket-server-api"; +import { HostContextProvider } from "../auth/host-context-provider"; + +@suite(timeout(10000), retries(1), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_BITBUCKET_SERVER")) +class TestBitbucketServerFileProvider { + protected service: BitbucketServerFileProvider; + protected user: User; + + static readonly AUTH_HOST_CONFIG: Partial = { + id: "MyBitbucketServer", + type: "BitbucketServer", + verified: true, + description: "", + icon: "", + host: "bitbucket.gitpod-self-hosted.com", + oauth: { + callBackUrl: "", + clientId: "not-used", + clientSecret: "", + tokenUrl: "", + scope: "", + authorizationUrl: "", + }, + }; + + public before() { + const container = new Container(); + container.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(BitbucketServerFileProvider).toSelf().inSingletonScope(); + bind(BitbucketServerContextParser).toSelf().inSingletonScope(); + bind(AuthProviderParams).toConstantValue(TestBitbucketServerFileProvider.AUTH_HOST_CONFIG); + bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + bind(TokenService).toConstantValue({ + createGitpodToken: async () => ({ token: { value: "foobar123-token" } }), + } as any); + bind(Config).toConstantValue({ + hostUrl: new GitpodHostUrl(), + }); + bind(TokenProvider).toConstantValue({ + getTokenForHost: async () => { + return { + value: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"] || "undefined", + scopes: [], + }; + }, + getFreshPortAuthenticationToken: undefined as any, + }); + bind(BitbucketServerApi).toSelf().inSingletonScope(); + bind(HostContextProvider).toConstantValue({ + get: (hostname: string) => { + authProvider: { + ("BBS"); + } + }, + }); + }), + ); + this.service = container.get(BitbucketServerFileProvider); + this.user = { + creationDate: "", + id: "user1", + identities: [ + { + authId: "user1", + authName: "AlexTugarev", + authProviderId: "MyBitbucketServer", + }, + ], + }; + } + + @test async test_getGitpodFileContent_ok() { + const result = await this.service.getGitpodFileContent( + { + revision: "master", + repository: { + cloneUrl: "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + webUrl: "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + name: "repo123", + repoKind: "projects", + owner: "FOO", + }, + } as any, + this.user, + ); + expect(result).not.to.be.empty; + expect(result).to.contain("tasks:"); + } + + @test async test_getLastChangeRevision_ok() { + const result = await this.service.getLastChangeRevision( + { + owner: "FOO", + name: "repo123", + repoKind: "projects", + revision: "foo", + host: "bitbucket.gitpod-self-hosted.com", + cloneUrl: "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + webUrl: "https://bitbucket.gitpod-self-hosted.com/projects/FOO/repos/repo123", + } as Repository, + "foo", + this.user, + "folder/sub/test.txt", + ); + expect(result).to.equal("1384b6842d73b8705feaf45f3e8aa41f00529042"); + } +} + +module.exports = new TestBitbucketServerFileProvider(); diff --git a/components/server/src/bitbucket-server/bitbucket-server-file-provider.ts b/components/server/src/bitbucket-server/bitbucket-server-file-provider.ts index a8012aec757a10..26e19a3ccd17d0 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-file-provider.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-file-provider.ts @@ -5,18 +5,16 @@ */ import { Commit, Repository, User } from "@gitpod/gitpod-protocol"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; import { FileProvider, MaybeContent } from "../repohost/file-provider"; +import { BitbucketServerApi } from "./bitbucket-server-api"; @injectable() export class BitbucketServerFileProvider implements FileProvider { + @inject(BitbucketServerApi) protected api: BitbucketServerApi; + public async getGitpodFileContent(commit: Commit, user: User): Promise { - return undefined; - // const yamlVersion1 = await Promise.all([ - // this.getFileContent(commit, user, '.gitpod.yml'), - // this.getFileContent(commit, user, '.gitpod') - // ]); - // return yamlVersion1.filter(f => !!f)[0]; + return this.getFileContent(commit, user, ".gitpod.yml"); } public async getLastChangeRevision( @@ -25,29 +23,32 @@ export class BitbucketServerFileProvider implements FileProvider { user: User, path: string, ): Promise { - // try { - // const api = await this.apiFactory.create(user); - // const fileMetaData = (await api.repositories.readSrc({ workspace: repository.owner, repo_slug: repository.name, commit: revisionOrBranch, path, format: "meta" })).data; - // return (fileMetaData as any).commit.hash; - // } catch (err) { - // log.error({ userId: user.id }, err); - // throw new Error(`Could not fetch ${path} of repository ${repository.owner}/${repository.name}: ${err}`); - // } - return "f00"; + const { owner, name, repoKind } = repository; + + if (!repoKind) { + throw new Error("Repo kind is missing."); + } + + const result = await this.api.getCommits(user, { + owner, + repoKind, + repositorySlug: name, + query: { limit: 1, path, shaOrRevision: revisionOrBranch }, + }); + return result.values![0].id; } public async getFileContent(commit: Commit, user: User, path: string) { - return undefined; - // if (!commit.revision) { - // return undefined; - // } - - // try { - // const api = await this.apiFactory.create(user); - // const contents = (await api.repositories.readSrc({ workspace: commit.repository.owner, repo_slug: commit.repository.name, commit: commit.revision, path })).data; - // return contents as string; - // } catch (err) { - // log.error({ userId: user.id }, err); - // } + if (!commit.revision || !commit.repository.webUrl) { + return undefined; + } + const { owner, name, repoKind } = commit.repository; + + try { + const result = await this.api.fetchContent(user, `/${repoKind}/${owner}/repos/${name}/raw/${path}`); + return result; + } catch (err) { + console.error({ userId: user.id }, err); + } } } diff --git a/components/server/src/bitbucket-server/bitbucket-server-oauth-scopes.ts b/components/server/src/bitbucket-server/bitbucket-server-oauth-scopes.ts index 84210a3cbcaa53..35903b3f622433 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-oauth-scopes.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-oauth-scopes.ts @@ -14,7 +14,10 @@ export namespace BitbucketServerOAuthScopes { /** Push over https, fork repo */ export const REPOSITORY_WRITE = "REPO_WRITE"; - export const ALL = [PUBLIC_REPOS, REPOSITORY_READ, REPOSITORY_WRITE]; + export const REPO_ADMIN = "REPO_ADMIN"; + export const PROJECT_ADMIN = "PROJECT_ADMIN"; + + export const ALL = [PUBLIC_REPOS, REPOSITORY_READ, REPOSITORY_WRITE, REPO_ADMIN, PROJECT_ADMIN]; export const Requirements = { /** diff --git a/components/server/src/bitbucket-server/bitbucket-server-repository-provider.spec.ts b/components/server/src/bitbucket-server/bitbucket-server-repository-provider.spec.ts new file mode 100644 index 00000000000000..f07567f2cadb8c --- /dev/null +++ b/components/server/src/bitbucket-server/bitbucket-server-repository-provider.spec.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { User } from "@gitpod/gitpod-protocol"; +import { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if"; +import { Container, ContainerModule } from "inversify"; +import { retries, suite, test, timeout } from "mocha-typescript"; +import { expect } from "chai"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import { AuthProviderParams } from "../auth/auth-provider"; +import { BitbucketServerContextParser } from "./bitbucket-server-context-parser"; +import { BitbucketServerTokenHelper } from "./bitbucket-server-token-handler"; +import { TokenService } from "../user/token-service"; +import { Config } from "../config"; +import { TokenProvider } from "../user/token-provider"; +import { BitbucketServerApi } from "./bitbucket-server-api"; +import { HostContextProvider } from "../auth/host-context-provider"; +import { BitbucketServerRepositoryProvider } from "./bitbucket-server-repository-provider"; + +@suite(timeout(10000), retries(0), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_BITBUCKET_SERVER")) +class TestBitbucketServerRepositoryProvider { + protected service: BitbucketServerRepositoryProvider; + protected user: User; + + static readonly AUTH_HOST_CONFIG: Partial = { + id: "MyBitbucketServer", + type: "BitbucketServer", + verified: true, + description: "", + icon: "", + host: "bitbucket.gitpod-self-hosted.com", + oauth: { + callBackUrl: "", + clientId: "not-used", + clientSecret: "", + tokenUrl: "", + scope: "", + authorizationUrl: "", + }, + }; + + public before() { + const container = new Container(); + container.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(BitbucketServerRepositoryProvider).toSelf().inSingletonScope(); + bind(BitbucketServerContextParser).toSelf().inSingletonScope(); + bind(AuthProviderParams).toConstantValue(TestBitbucketServerRepositoryProvider.AUTH_HOST_CONFIG); + bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + bind(TokenService).toConstantValue({ + createGitpodToken: async () => ({ token: { value: "foobar123-token" } }), + } as any); + bind(Config).toConstantValue({ + hostUrl: new GitpodHostUrl(), + }); + bind(TokenProvider).toConstantValue({ + getTokenForHost: async () => { + return { + value: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"] || "undefined", + scopes: [], + }; + }, + getFreshPortAuthenticationToken: undefined as any, + }); + bind(BitbucketServerApi).toSelf().inSingletonScope(); + bind(HostContextProvider).toConstantValue({}); + }), + ); + this.service = container.get(BitbucketServerRepositoryProvider); + this.user = { + creationDate: "", + id: "user1", + identities: [ + { + authId: "user1", + authName: "AlexTugarev", + authProviderId: "MyBitbucketServer", + }, + ], + }; + } + + @test async test_getRepo_ok() { + const result = await this.service.getRepo(this.user, "JLDEC", "jldec-repo-march-30"); + expect(result).to.deep.include({ + webUrl: "https://bitbucket.gitpod-self-hosted.com/projects/JLDEC/repos/jldec-repo-march-30", + cloneUrl: "https://bitbucket.gitpod-self-hosted.com/scm/jldec/jldec-repo-march-30.git", + }); + } + + @test async test_getBranch_ok() { + const result = await this.service.getBranch(this.user, "JLDEC", "jldec-repo-march-30", "main"); + expect(result).to.deep.include({ + name: "main", + }); + } + + @test async test_getBranches_ok() { + const result = await this.service.getBranches(this.user, "JLDEC", "jldec-repo-march-30"); + expect(result.length).to.be.gte(1); + expect(result[0]).to.deep.include({ + name: "main", + }); + } + + @test async test_getBranches_ok_2() { + try { + await this.service.getBranches(this.user, "mil", "gitpod-large-image"); + expect.fail("this should not happen while 'mil/gitpod-large-image' has NO default branch configured."); + } catch (error) { + expect(error.message).to.include( + "refs/heads/master is set as the default branch, but this branch does not exist", + ); + } + } + + @test async test_getCommitInfo_ok() { + const result = await this.service.getCommitInfo(this.user, "JLDEC", "jldec-repo-march-30", "test"); + expect(result).to.deep.include({ + author: "Alex Tugarev", + }); + } +} + +module.exports = new TestBitbucketServerRepositoryProvider(); diff --git a/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts b/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts index cdc645a37e3612..302a9b732f4a21 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts @@ -5,95 +5,142 @@ */ import { Branch, CommitInfo, Repository, User } from "@gitpod/gitpod-protocol"; -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; +import { RepoURL } from "../repohost"; import { RepositoryProvider } from "../repohost/repository-provider"; +import { BitbucketServerApi } from "./bitbucket-server-api"; @injectable() export class BitbucketServerRepositoryProvider implements RepositoryProvider { + @inject(BitbucketServerApi) protected api: BitbucketServerApi; + + protected async getOwnerKind(user: User, owner: string): Promise<"users" | "projects" | undefined> { + try { + await this.api.getProject(user, owner); + return "projects"; + } catch (error) { + // ignore + } + try { + await this.api.getUserProfile(user, owner); + return "users"; + } catch (error) { + // ignore + } + } + 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; - // 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; - // const defaultBranch = repo.mainbranch?.name; - // return { host, owner, name, cloneUrl, description, avatarUrl, webUrl, defaultBranch }; - throw new Error("getRepo unimplemented"); + const repoKind = await this.getOwnerKind(user, owner); + if (!repoKind) { + throw new Error(`Could not find project "${owner}"`); + } + + const repo = await this.api.getRepository(user, { + repoKind, + owner, + repositorySlug: name, + }); + const defaultBranch = await this.api.getDefaultBranch(user, { + repoKind, + owner, + repositorySlug: name, + }); + const cloneUrl = repo.links.clone.find((u) => u.name === "http")?.href!; + const webUrl = repo.links?.self[0]?.href?.replace(/\/browse$/, ""); + const host = RepoURL.parseRepoUrl(cloneUrl)!.host; + const avatarUrl = this.api.getAvatarUrl(owner); + return { + host, + owner, + name, + cloneUrl, + description: repo.description, + avatarUrl, + webUrl, + defaultBranch: defaultBranch.displayId, + }; } 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", - // } - // }; - throw new Error("getBranch unimplemented"); + const repoKind = await this.getOwnerKind(user, owner); + if (!repoKind) { + throw new Error(`Could not find project "${owner}"`); + } + const branch = await this.api.getBranch(user, { + repoKind, + owner, + repositorySlug: repo, + branchName, + }); + const commit = branch.latestCommitMetadata; + + return { + htmlUrl: branch.htmlUrl, + name: branch.displayId, + commit: { + sha: commit.id, + author: commit.author.displayName, + authorAvatarUrl: commit.author.avatarUrl, + authorDate: new Date(commit.authorTimestamp).toISOString(), + commitMessage: commit.message || "missing commit message", + }, + }; } async getBranches(user: User, owner: string, repo: string): Promise { 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", - // } - // }); - // } + + const repoKind = await this.getOwnerKind(user, owner); + if (!repoKind) { + throw new Error(`Could not find project "${owner}"`); + } + const branchesResult = await this.api.getBranches(user, { + repoKind, + owner, + repositorySlug: repo, + }); + for (const entry of branchesResult) { + const commit = entry.latestCommitMetadata; + + branches.push({ + htmlUrl: entry.htmlUrl, + name: entry.displayId, + commit: { + sha: commit.id, + author: commit.author.displayName, + authorAvatarUrl: commit.author.avatarUrl, + authorDate: new Date(commit.authorTimestamp).toISOString(), + commitMessage: commit.message || "missing commit message", + }, + }); + } return branches; } async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise { - 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, - // }; + const repoKind = await this.getOwnerKind(user, owner); + if (!repoKind) { + throw new Error(`Could not find project "${owner}"`); + } + + const commitsResult = await this.api.getCommits(user, { + owner, + repoKind, + repositorySlug: repo, + query: { shaOrRevision: ref, limit: 1 }, + }); + + if (commitsResult.values && commitsResult.values[0]) { + const commit = commitsResult.values[0]; + return { + sha: commit.id, + author: commit.author.displayName, + authorDate: new Date(commit.authorTimestamp).toISOString(), + commitMessage: commit.message || "missing commit message", + authorAvatarUrl: commit.author.avatarUrl, + }; + } } async getUserRepos(user: User): Promise { @@ -107,6 +154,19 @@ export class BitbucketServerRepositoryProvider implements RepositoryProvider { } async getCommitHistory(user: User, owner: string, repo: string, ref: string, maxDepth: number): Promise { - return []; + const repoKind = await this.getOwnerKind(user, owner); + if (!repoKind) { + throw new Error(`Could not find project "${owner}"`); + } + + const commitsResult = await this.api.getCommits(user, { + owner, + repoKind, + repositorySlug: repo, + query: { shaOrRevision: ref, limit: 1000 }, + }); + + const commits = commitsResult.values || []; + return commits.map((c) => c.id); } } diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts index 3cc226f149cae2..941e34ffa6aa3b 100644 --- a/components/server/src/projects/projects-service.ts +++ b/components/server/src/projects/projects-service.ts @@ -150,7 +150,12 @@ export class ProjectsService { const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined; const authProvider = hostContext && hostContext.authProvider.info; const type = authProvider && authProvider.authProviderType; - if (type === "GitLab" || type === "Bitbucket" || AuthProviderInfo.isGitHubEnterprise(authProvider)) { + if ( + type === "GitLab" || + type === "Bitbucket" || + type === "BitbucketServer" || + AuthProviderInfo.isGitHubEnterprise(authProvider) + ) { const repositoryService = hostContext?.services?.repositoryService; if (repositoryService) { // Note: For GitLab, we expect .canInstallAutomatedPrebuilds() to always return true, because earlier diff --git a/components/server/src/repohost/repo-url.ts b/components/server/src/repohost/repo-url.ts index c1fd7a6ed4cf45..69e3aa676b9102 100644 --- a/components/server/src/repohost/repo-url.ts +++ b/components/server/src/repohost/repo-url.ts @@ -18,7 +18,10 @@ export namespace RepoURL { } if (segments.length > 2) { const endSegment = segments[segments.length - 1]; - const ownerSegments = segments.slice(0, segments.length - 1); + let ownerSegments = segments.slice(0, segments.length - 1); + if (ownerSegments[0] === "scm") { + ownerSegments = ownerSegments.slice(1); + } const owner = ownerSegments.join("/"); const repo = endSegment.endsWith(".git") ? endSegment.slice(0, -4) : endSegment; return { host, owner, repo }; diff --git a/yarn.lock b/yarn.lock index 9cc856d197c0b5..05e1e24964a5c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,18 +2,6 @@ # yarn lockfile v1 -"@atlassian/bitbucket-server@^0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@atlassian/bitbucket-server/-/bitbucket-server-0.0.6.tgz#7a0678264083c851e50a66216e66021efe1638cf" - integrity sha512-92EpKlSPw0ZZXiS4Qt+1DKuDxSIntL2j8Q0CWc8o/nUNOJAW/D9szIgcef5VPTbVslHeb2C3gBSMNnETdykdmQ== - dependencies: - before-after-hook "^1.1.0" - btoa-lite "^1.0.0" - debug "^3.1.0" - is-plain-object "^2.0.4" - node-fetch "^2.1.2" - url-template "^2.0.8" - "@babel/code-frame@7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" @@ -4600,11 +4588,6 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" -before-after-hook@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.4.0.tgz#2b6bf23dca4f32e628fd2747c10a37c74a4b484d" - integrity sha512-l5r9ir56nda3qu14nAXIlyq1MmUSs0meCIaFAh8HwkFwP1F8eToOuS3ah2VAHHcY04jaYD7FpJC5JTXHYRbkzg== - before-after-hook@^2.1.0, before-after-hook@^2.2.0: version "2.2.2" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e" @@ -12139,7 +12122,7 @@ node-emoji@^1.11.0: dependencies: lodash "^4.17.21" -node-fetch@^2.1.2, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.5, node-fetch@^2.6.7: +node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.5, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==