Skip to content
4 changes: 4 additions & 0 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,10 @@ export interface Repository {
// The direct parent of this fork
parent: Repository;
};
/**
* Optional date when the repository was last pushed to.
*/
pushedAt?: string;
}

export interface RepositoryInfo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export class GithubRepositoryProvider implements RepositoryProvider {
const avatarUrl = repository.owner.avatar_url;
const webUrl = repository.html_url;
const defaultBranch = repository.default_branch;
return { host, owner, name: repo, cloneUrl, description, avatarUrl, webUrl, defaultBranch };
const pushedAt = repository.pushed_at;
return { host, owner, name: repo, cloneUrl, description, avatarUrl, webUrl, defaultBranch, pushedAt };
}

async getBranch(user: User, owner: string, repo: string, branch: string): Promise<Branch> {
Expand Down
54 changes: 54 additions & 0 deletions components/server/src/prebuilds/bitbucket-server-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2024 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 { RepositoryService } from "../repohost/repo-service";
import { User } from "@gitpod/gitpod-protocol";
import { inject, injectable } from "inversify";
import { BitbucketServerApi } from "../bitbucket-server/bitbucket-server-api";
import { BitbucketServerContextParser } from "../bitbucket-server/bitbucket-server-context-parser";
import { Config } from "../config";
import { BitbucketServerApp } from "./bitbucket-server-app";

@injectable()
export class BitbucketServerService extends RepositoryService {
constructor(
@inject(BitbucketServerApi) private readonly api: BitbucketServerApi,
@inject(Config) private readonly config: Config,
@inject(BitbucketServerContextParser) private readonly contextParser: BitbucketServerContextParser,
) {
super();
}

public async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
try {
const { owner, repoName, repoKind } = await this.contextParser.parseURL(user, cloneUrl);
const existing = await this.api.getWebhooks(user, {
repoKind,
repositorySlug: repoName,
owner,
});
if (!existing.values) {
return false;
}
const hookUrl = this.getHookUrl();

return existing.values.some((hook) => hook.url && hook.url.includes(hookUrl));
} catch (error) {
console.error("Failed to check if Gitpod webhook is enabled.", error, { cloneUrl });

return false;
}
}

protected getHookUrl() {
return this.config.hostUrl
.asPublicServices()
.with({
pathname: BitbucketServerApp.path,
})
.toString();
}
}
52 changes: 52 additions & 0 deletions components/server/src/prebuilds/bitbucket-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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 { RepositoryService } from "../repohost/repo-service";
import { User } from "@gitpod/gitpod-protocol";
import { inject, injectable } from "inversify";
import { BitbucketApiFactory } from "../bitbucket/bitbucket-api-factory";
import { BitbucketApp } from "./bitbucket-app";
import { Config } from "../config";
import { BitbucketContextParser } from "../bitbucket/bitbucket-context-parser";

@injectable()
export class BitbucketService extends RepositoryService {
constructor(
@inject(BitbucketApiFactory) private readonly api: BitbucketApiFactory,
@inject(Config) private readonly config: Config,
@inject(BitbucketContextParser) private readonly bitbucketContextParser: BitbucketContextParser,
) {
super();
}

public async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
try {
const api = await this.api.create(user);
const { owner, repoName } = await this.bitbucketContextParser.parseURL(user, cloneUrl);
const hooks = await api.repositories.listWebhooks({
repo_slug: repoName,
workspace: owner,
});
if (!hooks.data.values) {
return false;
}
return hooks.data.values.some((hook) => hook.url === this.getHookUrl());
} catch (error) {
console.error("Failed to check if Gitpod webhook is enabled.", error, { cloneUrl });

return false;
}
}

protected getHookUrl() {
return this.config.hostUrl
.asPublicServices()
.with({
pathname: BitbucketApp.path,
})
.toString();
}
}
108 changes: 108 additions & 0 deletions components/server/src/prebuilds/github-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Copyright (c) 2024 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 { RepositoryService } from "../repohost/repo-service";
import { inject, injectable } from "inversify";
import { GitHubApiError, GitHubRestApi } from "../github/api";
import { GitHubEnterpriseApp } from "./github-enterprise-app";
import { GithubContextParser } from "../github/github-context-parser";
import { User } from "@gitpod/gitpod-protocol";
import { Config } from "../config";
import { TokenService } from "../user/token-service";
import { RepoURL } from "../repohost";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { UnauthorizedError } from "../errors";
import { containsScopes } from "./token-scopes-inclusion";
import { GitHubOAuthScopes } from "@gitpod/public-api-common/lib/auth-providers";

@injectable()
export class GitHubService extends RepositoryService {
static PREBUILD_TOKEN_SCOPE = "prebuilds";

constructor(
@inject(GitHubRestApi) protected readonly githubApi: GitHubRestApi,
@inject(Config) private readonly config: Config,
@inject(TokenService) private readonly tokenService: TokenService,
@inject(GithubContextParser) private readonly githubContextParser: GithubContextParser,
) {
super();
}

async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> {
const parsedRepoUrl = RepoURL.parseRepoUrl(cloneUrl);
if (!parsedRepoUrl) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, `Clone URL not parseable.`);
}
let tokenEntry;
try {
const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
const webhooks = (await this.githubApi.run(user, (gh) => gh.repos.listWebhooks({ owner, repo }))).data;
for (const webhook of webhooks) {
if (webhook.config.url === this.getHookUrl()) {
await this.githubApi.run(user, (gh) =>
gh.repos.deleteWebhook({ owner, repo, hook_id: webhook.id }),
);
}
}
tokenEntry = await this.tokenService.createGitpodToken(user, GitHubService.PREBUILD_TOKEN_SCOPE, cloneUrl);
const config = {
url: this.getHookUrl(),
content_type: "json",
secret: user.id + "|" + tokenEntry.token.value,
};
await this.githubApi.run(user, (gh) => gh.repos.createWebhook({ owner, repo, config }));
} catch (error) {
// Hint: here we catch all GH API errors to forward them as Unauthorized to FE,
// eventually that should be done depending on the error code.
// Also, if user is not connected at all, then the GH API wrapper is throwing
// the same error type, but with `providerIsConnected: false`.

if (GitHubApiError.is(error)) {
// TODO check for `error.code`
throw UnauthorizedError.create({
host: parsedRepoUrl.host,
providerType: "GitHub",
repoName: parsedRepoUrl.repo,
requiredScopes: GitHubOAuthScopes.Requirements.DEFAULT,
providerIsConnected: true,
isMissingScopes: containsScopes(
tokenEntry?.token.scopes,
GitHubOAuthScopes.Requirements.PRIVATE_REPO,
),
});
}
throw error;
}
}

async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
try {
const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
const webhooks = (await this.githubApi.run(user, (gh) => gh.repos.listWebhooks({ owner, repo }))).data;
return webhooks.some((webhook) => webhook.config.url === this.getHookUrl());
} catch (error) {
if (GitHubApiError.is(error)) {
throw UnauthorizedError.create({
host: RepoURL.parseRepoUrl(cloneUrl)!.host,
providerType: "GitHub",
repoName: RepoURL.parseRepoUrl(cloneUrl)!.repo,
requiredScopes: GitHubOAuthScopes.Requirements.DEFAULT,
providerIsConnected: true,
});
}
throw error;
}
}

protected getHookUrl() {
return this.config.hostUrl
.asPublicServices()
.with({
pathname: GitHubEnterpriseApp.path,
})
.toString();
}
}
57 changes: 57 additions & 0 deletions components/server/src/prebuilds/gitlab-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2024 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 { RepositoryService } from "../repohost/repo-service";
import { User } from "@gitpod/gitpod-protocol";
import { inject, injectable } from "inversify";
import { GitLabApi, GitLab } from "../gitlab/api";
import { GitLabApp } from "./gitlab-app";
import { Config } from "../config";
import { GitlabContextParser } from "../gitlab/gitlab-context-parser";
import { RepoURL } from "../repohost";
import { UnauthorizedError } from "../errors";
import { GitLabOAuthScopes } from "@gitpod/public-api-common/lib/auth-providers";

@injectable()
export class GitlabService extends RepositoryService {
constructor(
@inject(GitLabApi) protected api: GitLabApi,
@inject(Config) private readonly config: Config,
@inject(GitlabContextParser) private readonly gitlabContextParser: GitlabContextParser,
) {
super();
}

public async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
try {
const { owner, repoName } = await this.gitlabContextParser.parseURL(user, cloneUrl);
const hooks = (await this.api.run(user, (g) =>
g.ProjectHooks.all(`${owner}/${repoName}`),
)) as unknown as GitLab.ProjectHook[];
return hooks.some((hook) => hook.url === this.getHookUrl());
} catch (error) {
if (GitLab.ApiError.is(error)) {
throw UnauthorizedError.create({
host: RepoURL.parseRepoUrl(cloneUrl)!.host,
providerType: "GitLab",
repoName: RepoURL.parseRepoUrl(cloneUrl)!.repo,
requiredScopes: GitLabOAuthScopes.Requirements.REPO,
providerIsConnected: true,
});
}
throw error;
}
}

private getHookUrl() {
return this.config.hostUrl
.asPublicServices()
.with({
pathname: GitLabApp.path,
})
.toString();
}
}
28 changes: 23 additions & 5 deletions components/server/src/projects/projects-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
FindPrebuildsParams,
Project,
User,
WebhookEvent,
CommitContext,
WebhookEvent,
} from "@gitpod/gitpod-protocol";
import { HostContextProvider } from "../auth/host-context-provider";
import { RepoURL } from "../repohost";
Expand All @@ -38,6 +38,7 @@ import { IDEService } from "../ide-service";
import type { PrebuildManager } from "../prebuilds/prebuild-manager";
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { ContextParser } from "../workspace/context-parser-service";
import { UnauthorizedError } from "../errors";

// to resolve circular dependency issues
export const LazyPrebuildManager = Symbol("LazyPrebuildManager");
Expand All @@ -55,8 +56,8 @@ export class ProjectsService {
@inject(Authorizer) private readonly auth: Authorizer,
@inject(IDEService) private readonly ideService: IDEService,
@inject(LazyPrebuildManager) private readonly prebuildManager: LazyPrebuildManager,
@inject(ContextParser) private readonly contextParser: ContextParser,
@inject(WebhookEventDB) private readonly webhookEventDb: WebhookEventDB,
@inject(ContextParser) private contextParser: ContextParser,

@inject(InstallationService) private readonly installationService: InstallationService,
) {}
Expand Down Expand Up @@ -554,21 +555,38 @@ export class ProjectsService {
): Promise<WebhookEvent | undefined> {
const context = (await this.contextParser.handle(ctx, user, project.cloneUrl)) as CommitContext;

const events = await this.webhookEventDb.findByCloneUrl(project.cloneUrl, 1);
const events = await this.webhookEventDb.findByCloneUrl(project.cloneUrl, 50);
if (events.length === 0) {
return undefined;
}

const hostContext = this.hostContextProvider.get(context.repository.host);
const repoProvider = hostContext?.services?.repositoryProvider;
if (!repoProvider) {
const repoService = hostContext?.services?.repositoryService;
if (!repoService) {
throw new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, `repo provider unavailable`);
}

try {
const webhookEnabled = await repoService.isGitpodWebhookEnabled(user, project.cloneUrl);
return webhookEnabled ? events[0] : undefined; // todo(ft): figure out what to return if webhook is enabled but we have no events
} catch (error) {
if (!UnauthorizedError.is(error) && error.message !== "unsupported") {
throw error;
}
}

const matchingEvent = events.find((event) => {
if (maxAge && Date.now() - new Date(event.creationTime).getTime() > maxAge) {
return false;
}

// If we know when the source repository was last pushed to, we can figure out if we received the push event after that
if (context.repository.pushedAt && new Date(event.creationTime) >= new Date(context.repository.pushedAt)) {
return true;
}

// If we know the commit hash, we can check if latest commit in the event matches the one we know.
// We do this check second, because pushing to the non-default branch might throw this off.
return context.revision === event.commit;
});

Expand Down
18 changes: 18 additions & 0 deletions components/server/src/repohost/repo-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2020 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 { injectable } from "inversify";

@injectable()
export class RepositoryService {
async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> {
throw new Error("unsupported");
}
async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
throw new Error("unsupported");
}
}
Loading