[bitbucket-server] support for projects and prebuilds

This commit is contained in:
Alex Tugarev 2022-04-04 07:27:16 +00:00 committed by Robo Quat
parent 9526f87665
commit 76b51bc224
26 changed files with 1669 additions and 229 deletions

View File

@ -43,19 +43,26 @@ export default function NewProject() {
const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]); const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]);
useEffect(() => { useEffect(() => {
if (user && selectedProviderHost === undefined) { (async () => {
if (user.identities.find((i) => i.authProviderId === "Public-GitLab")) { setAuthProviders(await getGitpodService().server.getAuthProviders());
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")) { useEffect(() => {
setSelectedProviderHost("bitbucket.org"); 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(() => { useEffect(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@ -385,7 +392,7 @@ export default function NewProject() {
> >
{toSimpleName(r.name)} {toSimpleName(r.name)}
</div> </div>
<p>Updated {moment(r.updatedAt).fromNow()}</p> {r.updatedAt && <p>Updated {moment(r.updatedAt).fromNow()}</p>}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="h-full my-auto flex self-center opacity-0 group-hover:opacity-100 items-center mr-2 text-right"> <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: {
const filteredProviders = () => const filteredProviders = () =>
props.authProviders.filter( 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 ( return (

View File

@ -307,7 +307,7 @@ export interface ProviderRepository {
account: string; account: string;
accountAvatarUrl: string; accountAvatarUrl: string;
cloneUrl: string; cloneUrl: string;
updatedAt: string; updatedAt?: string;
installationId?: number; installationId?: number;
installationUpdatedAt?: string; installationUpdatedAt?: string;

View File

@ -1015,6 +1015,8 @@ export interface Repository {
owner: string; owner: string;
name: string; name: string;
cloneUrl: string; cloneUrl: string;
/* Optional kind to differentiate between repositories of orgs/groups/projects and personal repos. */
repoKind?: string;
description?: string; description?: string;
avatarUrl?: string; avatarUrl?: string;
webUrl?: string; webUrl?: string;

View File

@ -9,6 +9,7 @@ import { HostContainerMapping } from "../../../src/auth/host-container-mapping";
import { gitlabContainerModuleEE } from "../gitlab/container-module"; import { gitlabContainerModuleEE } from "../gitlab/container-module";
import { bitbucketContainerModuleEE } from "../bitbucket/container-module"; import { bitbucketContainerModuleEE } from "../bitbucket/container-module";
import { gitHubContainerModuleEE } from "../github/container-module"; import { gitHubContainerModuleEE } from "../github/container-module";
import { bitbucketServerContainerModuleEE } from "../bitbucket-server/container-module";
@injectable() @injectable()
export class HostContainerMappingEE extends HostContainerMapping { export class HostContainerMappingEE extends HostContainerMapping {
@ -20,9 +21,8 @@ export class HostContainerMappingEE extends HostContainerMapping {
return (modules || []).concat([gitlabContainerModuleEE]); return (modules || []).concat([gitlabContainerModuleEE]);
case "Bitbucket": case "Bitbucket":
return (modules || []).concat([bitbucketContainerModuleEE]); return (modules || []).concat([bitbucketContainerModuleEE]);
// case "BitbucketServer": case "BitbucketServer":
// FIXME return (modules || []).concat([bitbucketServerContainerModuleEE]);
// return (modules || []).concat([bitbucketContainerModuleEE]);
case "GitHub": case "GitHub":
return (modules || []).concat([gitHubContainerModuleEE]); return (modules || []).concat([gitHubContainerModuleEE]);
default: default:

View File

@ -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();
});

View File

@ -58,6 +58,7 @@ import { Config } from "../../src/config";
import { SnapshotService } from "./workspace/snapshot-service"; import { SnapshotService } from "./workspace/snapshot-service";
import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support"; import { BitbucketAppSupport } from "./bitbucket/bitbucket-app-support";
import { UserCounter } from "./user/user-counter"; import { UserCounter } from "./user/user-counter";
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
rebind(Server).to(ServerEE).inSingletonScope(); rebind(Server).to(ServerEE).inSingletonScope();
@ -77,6 +78,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
bind(BitbucketApp).toSelf().inSingletonScope(); bind(BitbucketApp).toSelf().inSingletonScope();
bind(BitbucketAppSupport).toSelf().inSingletonScope(); bind(BitbucketAppSupport).toSelf().inSingletonScope();
bind(GitHubEnterpriseApp).toSelf().inSingletonScope(); bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
bind(BitbucketServerApp).toSelf().inSingletonScope();
bind(UserCounter).toSelf().inSingletonScope(); bind(UserCounter).toSelf().inSingletonScope();

View File

@ -0,0 +1,244 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/
import * as express from "express";
import { postConstruct, injectable, inject } from "inversify";
import { ProjectDB, TeamDB, UserDB } from "@gitpod/gitpod-db/lib";
import { PrebuildManager } from "../prebuilds/prebuild-manager";
import { TokenService } from "../../../src/user/token-service";
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { CommitContext, CommitInfo, Project, StartPrebuildResult, User } from "@gitpod/gitpod-protocol";
import { RepoURL } from "../../../src/repohost";
import { HostContextProvider } from "../../../src/auth/host-context-provider";
import { ContextParser } from "../../../src/workspace/context-parser-service";
@injectable()
export class BitbucketServerApp {
@inject(UserDB) protected readonly userDB: UserDB;
@inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager;
@inject(TokenService) protected readonly tokenService: TokenService;
@inject(ProjectDB) protected readonly projectDB: ProjectDB;
@inject(TeamDB) protected readonly teamDB: TeamDB;
@inject(ContextParser) protected readonly contextParser: ContextParser;
@inject(HostContextProvider) protected readonly hostCtxProvider: HostContextProvider;
protected _router = express.Router();
public static path = "/apps/bitbucketserver/";
@postConstruct()
protected init() {
this._router.post("/", async (req, res) => {
try {
const payload = req.body;
if (PushEventPayload.is(req.body)) {
const span = TraceContext.startSpan("BitbucketApp.handleEvent", {});
let queryToken = req.query["token"] as string;
if (typeof queryToken === "string") {
queryToken = decodeURIComponent(queryToken);
}
const user = await this.findUser({ span }, queryToken);
if (!user) {
// If the webhook installer is no longer found in Gitpod's DB
// we should send a UNAUTHORIZED signal.
res.statusCode = 401;
res.send();
return;
}
await this.handlePushHook({ span }, user, payload);
} else {
console.warn(`Ignoring unsupported BBS event.`, { headers: req.headers });
}
} catch (err) {
console.error(`Couldn't handle request.`, err, { headers: req.headers, reqBody: req.body });
} finally {
// we always respond with OK, when we received a valid event.
res.sendStatus(200);
}
});
}
protected async findUser(ctx: TraceContext, secretToken: string): Promise<User> {
const span = TraceContext.startSpan("BitbucketApp.findUser", ctx);
try {
span.setTag("secret-token", secretToken);
const [userid, tokenValue] = secretToken.split("|");
const user = await this.userDB.findUserById(userid);
if (!user) {
throw new Error("No user found for " + secretToken + " found.");
} else if (!!user.blocked) {
throw new Error(`Blocked user ${user.id} tried to start prebuild.`);
}
const identity = user.identities.find((i) => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID);
if (!identity) {
throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`);
}
const tokens = await this.userDB.findTokensForIdentity(identity);
const token = tokens.find((t) => t.token.value === tokenValue);
if (!token) {
throw new Error(`User ${user.id} has no token with given value.`);
}
return user;
} finally {
span.finish();
}
}
protected async handlePushHook(
ctx: TraceContext,
user: User,
event: PushEventPayload,
): Promise<StartPrebuildResult | undefined> {
const span = TraceContext.startSpan("Bitbucket.handlePushHook", ctx);
try {
const contextUrl = this.createContextUrl(event);
span.setTag("contextUrl", contextUrl);
const context = await this.contextParser.handle({ span }, user, contextUrl);
if (!CommitContext.is(context)) {
throw new Error("CommitContext exprected.");
}
const cloneUrl = context.repository.cloneUrl;
const commit = context.revision;
const projectAndOwner = await this.findProjectAndOwner(cloneUrl, user);
const config = await this.prebuildManager.fetchConfig({ span }, user, context);
if (!this.prebuildManager.shouldPrebuild(config)) {
console.log("Bitbucket push event: No config. No prebuild.");
return undefined;
}
console.debug("Bitbucket Server push event: Starting prebuild.", { contextUrl });
const commitInfo = await this.getCommitInfo(user, cloneUrl, commit);
const ws = await this.prebuildManager.startPrebuild(
{ span },
{
user: projectAndOwner.user,
project: projectAndOwner?.project,
context,
commitInfo,
},
);
return ws;
} finally {
span.finish();
}
}
private async getCommitInfo(user: User, repoURL: string, commitSHA: string) {
const parsedRepo = RepoURL.parseRepoUrl(repoURL)!;
const hostCtx = this.hostCtxProvider.get(parsedRepo.host);
let commitInfo: CommitInfo | undefined;
if (hostCtx?.services?.repositoryProvider) {
commitInfo = await hostCtx?.services?.repositoryProvider.getCommitInfo(
user,
parsedRepo.owner,
parsedRepo.repo,
commitSHA,
);
}
return commitInfo;
}
/**
* Finds the relevant user account and project to the provided webhook event information.
*
* First of all it tries to find the project for the given `cloneURL`, then it tries to
* find the installer, which is also supposed to be a team member. As a fallback, it
* looks for a team member which also has a bitbucket.org connection.
*
* @param cloneURL of the webhook event
* @param webhookInstaller the user account known from the webhook installation
* @returns a promise which resolves to a user account and an optional project.
*/
protected async findProjectAndOwner(
cloneURL: string,
webhookInstaller: User,
): Promise<{ user: User; project?: Project }> {
const project = await this.projectDB.findProjectByCloneUrl(cloneURL);
if (project) {
if (project.userId) {
const user = await this.userDB.findUserById(project.userId);
if (user) {
return { user, project };
}
} else if (project.teamId) {
const teamMembers = await this.teamDB.findMembersByTeam(project.teamId || "");
if (teamMembers.some((t) => t.userId === webhookInstaller.id)) {
return { user: webhookInstaller, project };
}
for (const teamMember of teamMembers) {
const user = await this.userDB.findUserById(teamMember.userId);
if (user && user.identities.some((i) => i.authProviderId === "Public-Bitbucket")) {
return { user, project };
}
}
}
}
return { user: webhookInstaller };
}
protected createContextUrl(event: PushEventPayload): string {
const projectBrowseUrl = event.repository.links.self[0].href;
const branchName = event.changes[0].ref.displayId;
const contextUrl = `${projectBrowseUrl}?at=${encodeURIComponent(branchName)}`;
return contextUrl;
}
get router(): express.Router {
return this._router;
}
}
interface PushEventPayload {
eventKey: "repo:refs_changed" | string;
date: string;
actor: {
name: string;
emailAddress: string;
id: number;
displayName: string;
slug: string;
type: "NORMAL" | string;
};
repository: {
slug: string;
id: number;
name: string;
project: {
key: string;
id: number;
name: string;
public: boolean;
type: "NORMAL" | "PERSONAL";
};
links: {
clone: {
href: string;
name: string;
}[];
self: {
href: string;
}[];
};
public: boolean;
};
changes: {
ref: {
id: string;
displayId: string;
type: "BRANCH" | string;
};
refId: string;
fromHash: string;
toHash: string;
type: "UPDATE" | string;
}[];
}
namespace PushEventPayload {
export function is(payload: any): payload is PushEventPayload {
return typeof payload === "object" && "eventKey" in payload && payload["eventKey"] === "repo:refs_changed";
}
}

View File

@ -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<AuthProviderParams> = {
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(<TokenProvider>{
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();

View File

@ -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<ProviderRepository[]> {
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 <ProviderRepository>{
name: r.name,
cloneUrl,
account: r.project.key,
accountAvatarUrl,
// updatedAt: TODO(at): this isn't provided directly
};
});
}
async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise<boolean> {
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<void> {
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();
}
}

View File

@ -25,13 +25,12 @@ export class BitbucketService extends RepositoryService {
@inject(BitbucketContextParser) protected bitbucketContextParser: BitbucketContextParser; @inject(BitbucketContextParser) protected bitbucketContextParser: BitbucketContextParser;
async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise<boolean> { async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise<boolean> {
const { host } = await this.bitbucketContextParser.parseURL(user, cloneUrl); const { host, owner, repoName } = await this.bitbucketContextParser.parseURL(user, cloneUrl);
if (host !== this.authProviderConfig.host) { if (host !== this.authProviderConfig.host) {
return false; return false;
} }
// only admins may install webhooks on repositories // only admins may install webhooks on repositories
const { owner, repoName } = await this.bitbucketContextParser.parseURL(user, cloneUrl);
const api = await this.api.create(user); const api = await this.api.create(user);
const response = await api.user.listPermissionsForRepos({ const response = await api.user.listPermissionsForRepos({
q: `repository.full_name="${owner}/${repoName}"`, q: `repository.full_name="${owner}/${repoName}"`,

View File

@ -14,11 +14,13 @@ import { BitbucketApp } from "./prebuilds/bitbucket-app";
import { GithubApp } from "./prebuilds/github-app"; import { GithubApp } from "./prebuilds/github-app";
import { SnapshotService } from "./workspace/snapshot-service"; import { SnapshotService } from "./workspace/snapshot-service";
import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app"; import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app";
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Server<C, S> { export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Server<C, S> {
@inject(GithubApp) protected readonly githubApp: GithubApp; @inject(GithubApp) protected readonly githubApp: GithubApp;
@inject(GitLabApp) protected readonly gitLabApp: GitLabApp; @inject(GitLabApp) protected readonly gitLabApp: GitLabApp;
@inject(BitbucketApp) protected readonly bitbucketApp: BitbucketApp; @inject(BitbucketApp) protected readonly bitbucketApp: BitbucketApp;
@inject(BitbucketServerApp) protected readonly bitbucketServerApp: BitbucketServerApp;
@inject(SnapshotService) protected readonly snapshotService: SnapshotService; @inject(SnapshotService) protected readonly snapshotService: SnapshotService;
@inject(GitHubEnterpriseApp) protected readonly gitHubEnterpriseApp: GitHubEnterpriseApp; @inject(GitHubEnterpriseApp) protected readonly gitHubEnterpriseApp: GitHubEnterpriseApp;
@ -48,5 +50,8 @@ export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Se
log.info("Registered GitHub EnterpriseApp app at " + GitHubEnterpriseApp.path); log.info("Registered GitHub EnterpriseApp app at " + GitHubEnterpriseApp.path);
app.use(GitHubEnterpriseApp.path, this.gitHubEnterpriseApp.router); app.use(GitHubEnterpriseApp.path, this.gitHubEnterpriseApp.router);
log.info("Registered Bitbucket Server app at " + BitbucketServerApp.path);
app.use(BitbucketServerApp.path, this.bitbucketServerApp.router);
} }
} }

View File

@ -1785,6 +1785,13 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
} }
} else if (providerHost === "bitbucket.org" && provider) { } else if (providerHost === "bitbucket.org" && provider) {
repositories.push(...(await this.bitbucketAppSupport.getProviderRepositoriesForUser({ user, 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") { } else if (provider?.authProviderType === "GitLab") {
repositories.push(...(await this.gitLabAppSupport.getProviderRepositoriesForUser({ user, provider }))); repositories.push(...(await this.gitLabAppSupport.getProviderRepositoriesForUser({ user, provider })));
} else { } else {

View File

@ -27,7 +27,6 @@
"/dist" "/dist"
], ],
"dependencies": { "dependencies": {
"@atlassian/bitbucket-server": "^0.0.6",
"@gitbeaker/node": "^25.6.0", "@gitbeaker/node": "^25.6.0",
"@gitpod/content-service": "0.1.5", "@gitpod/content-service": "0.1.5",
"@gitpod/gitpod-db": "0.1.5", "@gitpod/gitpod-db": "0.1.5",

View File

@ -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<AuthProviderParams> = {
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(<TokenProvider>{
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();

View File

@ -15,71 +15,310 @@ export class BitbucketServerApi {
@inject(AuthProviderParams) protected readonly config: AuthProviderParams; @inject(AuthProviderParams) protected readonly config: AuthProviderParams;
@inject(BitbucketServerTokenHelper) protected readonly tokenHelper: BitbucketServerTokenHelper; @inject(BitbucketServerTokenHelper) protected readonly tokenHelper: BitbucketServerTokenHelper;
public async runQuery<T>(user: User, urlPath: string): Promise<T> { public async runQuery<T>(
userOrToken: User | string,
urlPath: string,
method: string = "GET",
body?: string,
): Promise<T> {
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<string> {
const token = (await this.tokenHelper.getTokenWithScopes(user, [])).value; const token = (await this.tokenHelper.getTokenWithScopes(user, [])).value;
const fullUrl = `${this.baseUrl}${urlPath}`; const fullUrl = `${this.baseUrl}${urlPath}`;
const response = await fetch(fullUrl, { let result: string = "OK";
timeout: 10000, try {
method: "GET", const response = await fetch(fullUrl, {
headers: { timeout: 10000,
"Content-Type": "application/json", method: "GET",
Authorization: `Bearer ${token}`, headers: {
}, Authorization: `Bearer ${token}`,
}); },
if (!response.ok) { });
throw Error(response.statusText);
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<string> {
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<BitbucketServer.User> {
return this.runQuery<BitbucketServer.User>(userOrToken, `/users/${username}`);
}
async getProject(userOrToken: User | string, projectSlug: string): Promise<BitbucketServer.Project> {
return this.runQuery<BitbucketServer.Project>(userOrToken, `/projects/${projectSlug}`);
}
async getPermission(
user: User,
params: { username: string; repoKind: BitbucketServer.RepoKind; owner: string; repoName?: string },
): Promise<string | undefined> {
const { username, repoKind, owner, repoName } = params;
if (repoName) {
const repoPermissions = await this.runQuery<BitbucketServer.Paginated<BitbucketServer.PermissionEntry>>(
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<BitbucketServer.Paginated<BitbucketServer.PermissionEntry>>(
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 { protected get baseUrl(): string {
return `https://${this.config.host}/rest/api/1.0`; return `https://${this.config.host}/rest/api/1.0`;
} }
getRepository( async getRepository(
user: User, user: User,
params: { kind: "projects" | "users"; userOrProject: string; repositorySlug: string }, params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string },
): Promise<BitbucketServer.Repository> { ): Promise<BitbucketServer.Repository> {
return this.runQuery<BitbucketServer.Repository>( return this.runQuery<BitbucketServer.Repository>(
user, user,
`/${params.kind}/${params.userOrProject}/repos/${params.repositorySlug}`, `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}`,
); );
} }
getCommits( async getCommits(
user: User, 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<BitbucketServer.Paginated<BitbucketServer.Commit>> { ): Promise<BitbucketServer.Paginated<BitbucketServer.Commit>> {
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<BitbucketServer.Paginated<BitbucketServer.Commit>>( return this.runQuery<BitbucketServer.Paginated<BitbucketServer.Commit>>(
user, user,
`/${params.kind}/${params.userOrProject}/repos/${params.repositorySlug}/commits`, `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}/commits${q}`,
); );
} }
getDefaultBranch( async getDefaultBranch(
user: User, user: User,
params: { kind: "projects" | "users"; userOrProject: string; repositorySlug: string }, params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string },
): Promise<BitbucketServer.Branch> { ): Promise<BitbucketServer.Branch> {
//https://bitbucket.gitpod-self-hosted.com/rest/api/1.0/users/jldec/repos/test-repo/default-branch
return this.runQuery<BitbucketServer.Branch>( return this.runQuery<BitbucketServer.Branch>(
user, 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<BitbucketServer.BranchWithMeta> {
const result = await this.runQuery<BitbucketServer.Paginated<BitbucketServer.BranchWithMeta>>(
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<BitbucketServer.BranchWithMeta[]> {
const result = await this.runQuery<BitbucketServer.Paginated<BitbucketServer.BranchWithMeta>>(
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<BitbucketServer.Paginated<BitbucketServer.Webhook>> {
return this.runQuery<BitbucketServer.Paginated<BitbucketServer.Webhook>>(
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<any>(
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<BitbucketServer.Paginated<BitbucketServer.Repository>>(user, `/repos${q}`);
}
} }
export namespace BitbucketServer { export namespace BitbucketServer {
export type RepoKind = "users" | "projects";
export interface Repository { export interface Repository {
id: number; id: number;
slug: string; slug: string;
name: string; name: string;
description?: string;
public: boolean; public: boolean;
links: { links: {
clone: { clone: {
href: string; href: string;
name: string; name: string;
}[]; }[];
self: {
href: string;
}[];
}; };
project: Project; project: Project;
} }
@ -100,6 +339,14 @@ export namespace BitbucketServer {
isDefault: boolean; 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 { export interface User {
name: string; name: string;
emailAddress: string; emailAddress: string;
@ -115,12 +362,17 @@ export namespace BitbucketServer {
}, },
]; ];
}; };
avatarUrl?: string;
} }
export interface Commit { export interface Commit {
id: string; id: string;
displayId: string; displayId: string;
author: BitbucketServer.User; author: BitbucketServer.User;
authorTimestamp: number;
commiter: BitbucketServer.User;
committerTimestamp: number;
message: string;
} }
export interface Paginated<T> { export interface Paginated<T> {
@ -131,4 +383,45 @@ export namespace BitbucketServer {
values?: T[]; values?: T[];
[k: string]: any; [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;
}
}
} }

View File

@ -7,15 +7,16 @@
import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import * as express from "express"; import * as express from "express";
import { injectable } from "inversify"; import { inject, injectable } from "inversify";
import fetch from "node-fetch";
import { AuthUserSetup } from "../auth/auth-provider"; import { AuthUserSetup } from "../auth/auth-provider";
import { GenericAuthProvider } from "../auth/generic-auth-provider"; import { GenericAuthProvider } from "../auth/generic-auth-provider";
import { BitbucketServerOAuthScopes } from "./bitbucket-server-oauth-scopes"; import { BitbucketServerOAuthScopes } from "./bitbucket-server-oauth-scopes";
import * as BitbucketServer from "@atlassian/bitbucket-server"; import { BitbucketServerApi } from "./bitbucket-server-api";
@injectable() @injectable()
export class BitbucketServerAuthProvider extends GenericAuthProvider { export class BitbucketServerAuthProvider extends GenericAuthProvider {
@inject(BitbucketServerApi) protected readonly api: BitbucketServerApi;
get info(): AuthProviderInfo { get info(): AuthProviderInfo {
return { return {
...this.defaultInfo(), ...this.defaultInfo(),
@ -54,41 +55,18 @@ export class BitbucketServerAuthProvider extends GenericAuthProvider {
protected readAuthUserSetup = async (accessToken: string, _tokenResponse: object) => { protected readAuthUserSetup = async (accessToken: string, _tokenResponse: object) => {
try { try {
const fetchResult = await fetch(`https://${this.params.host}/plugins/servlet/applinks/whoami`, { const username = await this.api.currentUsername(accessToken);
timeout: 10000, const userProfile = await this.api.getUserProfile(accessToken, username);
headers: { const avatarUrl = await this.api.getAvatarUrl(username);
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?
return <AuthUserSetup>{ return <AuthUserSetup>{
authUser: { authUser: {
authId: `${user.id!}`, // e.g. 105
authName: user.slug!, authId: `${userProfile.id!}`,
primaryEmail: user.emailAddress!, // HINT: userProfile.name is used to match permission in repo/webhook services
name: user.displayName!, authName: userProfile.name,
// avatarUrl: user.links!.avatar!.href // TODO primaryEmail: userProfile.emailAddress!,
name: userProfile.displayName!,
avatarUrl,
}, },
currentScopes: BitbucketServerOAuthScopes.ALL, currentScopes: BitbucketServerOAuthScopes.ALL,
}; };

View File

@ -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<AuthProviderParams> = {
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(<TokenProvider>{
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();

View File

@ -25,12 +25,18 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen
const span = TraceContext.startSpan("BitbucketServerContextParser.handle", ctx); const span = TraceContext.startSpan("BitbucketServerContextParser.handle", ctx);
try { try {
const { resourceKind, host, owner, repoName /*moreSegments, searchParams*/ } = await this.parseURL( const more: Partial<NavigatorContext> = {};
const { repoKind, host, owner, repoName, /*moreSegments*/ searchParams } = await this.parseURL(
user, user,
contextUrl, 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) { } catch (e) {
span.addTags({ contextUrl }).log({ error: e }); span.addTags({ contextUrl }).log({ error: e });
log.error({ userId: user.id }, "Error parsing Bitbucket context", 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 url = new URL(contextUrl);
const pathname = url.pathname.replace(/^\//, "").replace(/\/$/, ""); // pathname without leading and trailing slash const pathname = url.pathname.replace(/^\//, "").replace(/\/$/, ""); // pathname without leading and trailing slash
const segments = pathname.split("/"); const segments = pathname.split("/");
@ -54,14 +60,31 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen
segments.splice(0, lenghtOfRelativePath); segments.splice(0, lenghtOfRelativePath);
} }
const resourceKind = segments[0]; let firstSegment = segments[0];
const owner: string = segments[1]; let owner: string = segments[1];
const repoName: string = segments[3]; let repoKind: "users" | "projects";
const moreSegmentsStart: number = 4; 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 endsWithRepoName = segments.length === moreSegmentsStart;
const searchParams = url.searchParams; const searchParams = url.searchParams;
return { return {
resourceKind, repoKind,
host, host,
owner, owner,
repoName: this.parseRepoName(repoName, endsWithRepoName), repoName: this.parseRepoName(repoName, endsWithRepoName),
@ -83,7 +106,7 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen
protected async handleNavigatorContext( protected async handleNavigatorContext(
ctx: TraceContext, ctx: TraceContext,
user: User, user: User,
resourceKind: string, repoKind: "projects" | "users",
host: string, host: string,
owner: string, owner: string,
repoName: string, repoName: string,
@ -91,20 +114,17 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen
): Promise<NavigatorContext> { ): Promise<NavigatorContext> {
const span = TraceContext.startSpan("BitbucketServerContextParser.handleNavigatorContext", ctx); const span = TraceContext.startSpan("BitbucketServerContextParser.handleNavigatorContext", ctx);
try { try {
if (resourceKind !== "users" && resourceKind !== "projects") {
throw new Error("Only /users/ and /projects/ resources are supported.");
}
const repo = await this.api.getRepository(user, { const repo = await this.api.getRepository(user, {
kind: resourceKind, repoKind,
userOrProject: owner, owner,
repositorySlug: repoName, repositorySlug: repoName,
}); });
const defaultBranch = await this.api.getDefaultBranch(user, { const defaultBranch = await this.api.getDefaultBranch(user, {
kind: resourceKind, repoKind,
userOrProject: owner, owner,
repositorySlug: repoName, repositorySlug: repoName,
}); });
const repository = await this.toRepository(user, host, repo, defaultBranch); const repository = this.toRepository(host, repo, repoKind, defaultBranch);
span.log({ "request.finished": "" }); span.log({ "request.finished": "" });
if (!repo) { if (!repo) {
@ -124,10 +144,10 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen
if (!more.revision) { if (!more.revision) {
const tipCommitOnDefaultBranch = await this.api.getCommits(user, { const tipCommitOnDefaultBranch = await this.api.getCommits(user, {
kind: resourceKind, repoKind,
userOrProject: owner, owner,
repositorySlug: repoName, repositorySlug: repoName,
q: { limit: 1 }, query: { limit: 1 },
}); });
const commits = tipCommitOnDefaultBranch?.values || []; const commits = tipCommitOnDefaultBranch?.values || [];
if (commits.length === 0) { if (commits.length === 0) {
@ -137,15 +157,19 @@ export class BitbucketServerContextParser extends AbstractContextParser implemen
more.refType = undefined; more.refType = undefined;
} else { } else {
more.revision = commits[0].id; more.revision = commits[0].id;
more.refType = "revision"; // more.refType = "revision";
} }
} }
return { return <NavigatorContext>{
...more, isFile: false,
path: "",
title: `${owner}/${repoName} - ${more.ref || more.revision}${more.path ? ":" + more.path : ""}`, title: `${owner}/${repoName} - ${more.ref || more.revision}${more.path ? ":" + more.path : ""}`,
ref: more.ref,
refType: more.refType,
revision: more.revision,
repository, repository,
} as NavigatorContext; };
} catch (e) { } catch (e) {
span.log({ error: e }); span.log({ error: e });
log.error({ userId: user.id }, "Error parsing Bitbucket navigator request context", 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( protected toRepository(
user: User,
host: string, host: string,
repo: BitbucketServer.Repository, repo: BitbucketServer.Repository,
repoKind: string,
defaultBranch: BitbucketServer.Branch, defaultBranch: BitbucketServer.Branch,
): Promise<Repository> { ): Repository {
if (!repo) {
throw new Error("Unknown repository.");
}
const owner = repo.project.owner ? repo.project.owner.slug : repo.project.key; const owner = repo.project.owner ? repo.project.owner.slug : repo.project.key;
const name = repo.name; const name = repo.name;
const cloneUrl = repo.links.clone.find((u) => u.name === "http")?.href!; const cloneUrl = repo.links.clone.find((u) => u.name === "http")?.href!;
const webUrl = repo.links?.self[0]?.href?.replace(/\/browse$/, "");
const result: Repository = { const result: Repository = {
webUrl,
cloneUrl, cloneUrl,
host, host,
name, name,
owner, owner,
repoKind,
private: !repo.public, private: !repo.public,
defaultBranch: defaultBranch.displayId || DEFAULT_BRANCH, defaultBranch: defaultBranch.displayId || DEFAULT_BRANCH,
}; };

View File

@ -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<AuthProviderParams> = {
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(<TokenProvider>{
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: <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();

View File

@ -5,18 +5,16 @@
*/ */
import { Commit, Repository, User } from "@gitpod/gitpod-protocol"; 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 { FileProvider, MaybeContent } from "../repohost/file-provider";
import { BitbucketServerApi } from "./bitbucket-server-api";
@injectable() @injectable()
export class BitbucketServerFileProvider implements FileProvider { export class BitbucketServerFileProvider implements FileProvider {
@inject(BitbucketServerApi) protected api: BitbucketServerApi;
public async getGitpodFileContent(commit: Commit, user: User): Promise<MaybeContent> { public async getGitpodFileContent(commit: Commit, user: User): Promise<MaybeContent> {
return undefined; return this.getFileContent(commit, user, ".gitpod.yml");
// const yamlVersion1 = await Promise.all([
// this.getFileContent(commit, user, '.gitpod.yml'),
// this.getFileContent(commit, user, '.gitpod')
// ]);
// return yamlVersion1.filter(f => !!f)[0];
} }
public async getLastChangeRevision( public async getLastChangeRevision(
@ -25,29 +23,32 @@ export class BitbucketServerFileProvider implements FileProvider {
user: User, user: User,
path: string, path: string,
): Promise<string> { ): Promise<string> {
// try { const { owner, name, repoKind } = repository;
// 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; if (!repoKind) {
// return (fileMetaData as any).commit.hash; throw new Error("Repo kind is missing.");
// } catch (err) { }
// log.error({ userId: user.id }, err);
// throw new Error(`Could not fetch ${path} of repository ${repository.owner}/${repository.name}: ${err}`); const result = await this.api.getCommits(user, {
// } owner,
return "f00"; repoKind,
repositorySlug: name,
query: { limit: 1, path, shaOrRevision: revisionOrBranch },
});
return result.values![0].id;
} }
public async getFileContent(commit: Commit, user: User, path: string) { public async getFileContent(commit: Commit, user: User, path: string) {
return undefined; if (!commit.revision || !commit.repository.webUrl) {
// if (!commit.revision) { return undefined;
// return undefined; }
// } const { owner, name, repoKind } = commit.repository;
// try { try {
// const api = await this.apiFactory.create(user); const result = await this.api.fetchContent(user, `/${repoKind}/${owner}/repos/${name}/raw/${path}`);
// const contents = (await api.repositories.readSrc({ workspace: commit.repository.owner, repo_slug: commit.repository.name, commit: commit.revision, path })).data; return result;
// return contents as string; } catch (err) {
// } catch (err) { console.error({ userId: user.id }, err);
// log.error({ userId: user.id }, err); }
// }
} }
} }

View File

@ -14,7 +14,10 @@ export namespace BitbucketServerOAuthScopes {
/** Push over https, fork repo */ /** Push over https, fork repo */
export const REPOSITORY_WRITE = "REPO_WRITE"; 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 = { export const Requirements = {
/** /**

View File

@ -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<AuthProviderParams> = {
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(<TokenProvider>{
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();

View File

@ -5,95 +5,142 @@
*/ */
import { Branch, CommitInfo, Repository, User } from "@gitpod/gitpod-protocol"; 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 { RepositoryProvider } from "../repohost/repository-provider";
import { BitbucketServerApi } from "./bitbucket-server-api";
@injectable() @injectable()
export class BitbucketServerRepositoryProvider implements RepositoryProvider { 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<Repository> { async getRepo(user: User, owner: string, name: string): Promise<Repository> {
// const api = await this.apiFactory.create(user); const repoKind = await this.getOwnerKind(user, owner);
// const repo = (await api.repositories.get({ workspace: owner, repo_slug: name })).data; if (!repoKind) {
// let cloneUrl = repo.links!.clone!.find((x: any) => x.name === "https")!.href!; throw new Error(`Could not find project "${owner}"`);
// if (cloneUrl) { }
// const url = new URL(cloneUrl);
// url.username = ''; const repo = await this.api.getRepository(user, {
// cloneUrl = url.toString(); repoKind,
// } owner,
// const host = RepoURL.parseRepoUrl(cloneUrl)!.host; repositorySlug: name,
// const description = repo.description; });
// const avatarUrl = repo.owner!.links!.avatar!.href; const defaultBranch = await this.api.getDefaultBranch(user, {
// const webUrl = repo.links!.html!.href; repoKind,
// const defaultBranch = repo.mainbranch?.name; owner,
// return { host, owner, name, cloneUrl, description, avatarUrl, webUrl, defaultBranch }; repositorySlug: name,
throw new Error("getRepo unimplemented"); });
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<Branch> { async getBranch(user: User, owner: string, repo: string, branchName: string): Promise<Branch> {
// const api = await this.apiFactory.create(user); const repoKind = await this.getOwnerKind(user, owner);
// const response = await api.repositories.getBranch({ if (!repoKind) {
// workspace: owner, throw new Error(`Could not find project "${owner}"`);
// repo_slug: repo, }
// name: branchName const branch = await this.api.getBranch(user, {
// }) repoKind,
owner,
repositorySlug: repo,
branchName,
});
const commit = branch.latestCommitMetadata;
// const branch = response.data; return {
htmlUrl: branch.htmlUrl,
// return { name: branch.displayId,
// htmlUrl: branch.links?.html?.href!, commit: {
// name: branch.name!, sha: commit.id,
// commit: { author: commit.author.displayName,
// sha: branch.target?.hash!, authorAvatarUrl: commit.author.avatarUrl,
// author: branch.target?.author?.user?.display_name!, authorDate: new Date(commit.authorTimestamp).toISOString(),
// authorAvatarUrl: branch.target?.author?.user?.links?.avatar?.href, commitMessage: commit.message || "missing commit message",
// authorDate: branch.target?.date!, },
// commitMessage: branch.target?.message || "missing commit message", };
// }
// };
throw new Error("getBranch unimplemented");
} }
async getBranches(user: User, owner: string, repo: string): Promise<Branch[]> { async getBranches(user: User, owner: string, repo: string): Promise<Branch[]> {
const branches: Branch[] = []; 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!) { const repoKind = await this.getOwnerKind(user, owner);
// branches.push({ if (!repoKind) {
// htmlUrl: branch.links?.html?.href!, throw new Error(`Could not find project "${owner}"`);
// name: branch.name!, }
// commit: { const branchesResult = await this.api.getBranches(user, {
// sha: branch.target?.hash!, repoKind,
// author: branch.target?.author?.user?.display_name!, owner,
// authorAvatarUrl: branch.target?.author?.user?.links?.avatar?.href, repositorySlug: repo,
// authorDate: branch.target?.date!, });
// commitMessage: branch.target?.message || "missing commit message", 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; return branches;
} }
async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise<CommitInfo | undefined> { async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise<CommitInfo | undefined> {
return undefined; const repoKind = await this.getOwnerKind(user, owner);
// const api = await this.apiFactory.create(user); if (!repoKind) {
// const response = await api.commits.get({ throw new Error(`Could not find project "${owner}"`);
// workspace: owner, }
// repo_slug: repo,
// commit: ref const commitsResult = await this.api.getCommits(user, {
// }) owner,
// const commit = response.data; repoKind,
// return { repositorySlug: repo,
// sha: commit.hash!, query: { shaOrRevision: ref, limit: 1 },
// author: commit.author?.user?.display_name!, });
// authorDate: commit.date!,
// commitMessage: commit.message || "missing commit message", if (commitsResult.values && commitsResult.values[0]) {
// authorAvatarUrl: commit.author?.user?.links?.avatar?.href, 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<string[]> { async getUserRepos(user: User): Promise<string[]> {
@ -107,6 +154,19 @@ export class BitbucketServerRepositoryProvider implements RepositoryProvider {
} }
async getCommitHistory(user: User, owner: string, repo: string, ref: string, maxDepth: number): Promise<string[]> { async getCommitHistory(user: User, owner: string, repo: string, ref: string, maxDepth: number): Promise<string[]> {
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);
} }
} }

View File

@ -150,7 +150,12 @@ export class ProjectsService {
const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined; const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined;
const authProvider = hostContext && hostContext.authProvider.info; const authProvider = hostContext && hostContext.authProvider.info;
const type = authProvider && authProvider.authProviderType; 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; const repositoryService = hostContext?.services?.repositoryService;
if (repositoryService) { if (repositoryService) {
// Note: For GitLab, we expect .canInstallAutomatedPrebuilds() to always return true, because earlier // Note: For GitLab, we expect .canInstallAutomatedPrebuilds() to always return true, because earlier

View File

@ -18,7 +18,10 @@ export namespace RepoURL {
} }
if (segments.length > 2) { if (segments.length > 2) {
const endSegment = segments[segments.length - 1]; 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 owner = ownerSegments.join("/");
const repo = endSegment.endsWith(".git") ? endSegment.slice(0, -4) : endSegment; const repo = endSegment.endsWith(".git") ? endSegment.slice(0, -4) : endSegment;
return { host, owner, repo }; return { host, owner, repo };

View File

@ -2,18 +2,6 @@
# yarn lockfile v1 # 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": "@babel/code-frame@7.10.4":
version "7.10.4" version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" 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: dependencies:
tweetnacl "^0.14.3" 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: before-after-hook@^2.1.0, before-after-hook@^2.2.0:
version "2.2.2" version "2.2.2"
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e" 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: dependencies:
lodash "^4.17.21" 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" version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==