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
8 changes: 6 additions & 2 deletions components/server/src/api/configuration-service-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,14 @@ export class ConfigurationServiceAPI implements ServiceImpl<typeof Configuration
throw new ApplicationError(ErrorCodes.NOT_FOUND, "configuration not found");
}
const user = await this.userService.findUserById(ctxUserId(), ctxUserId());
const event = await this.projectService.getRecentWebhookEvent({}, user, configuration, 7 * 24 * 60 * 60 * 1000);
let event = await this.projectService.getRecentWebhookEvent({}, user, configuration, 7 * 24 * 60 * 60 * 1000);
const isWebhookActive = event !== undefined;
if (event?.id === "n/a") {
event = undefined; // if we know webhooks are enabled but we never received an event,
}

const resp = new GetConfigurationWebhookActivityStatusResponse({
isWebhookActive: event !== undefined,
isWebhookActive,
latestWebhookEvent: {
commit: event?.commit,
creationTime: event?.creationTime ? Timestamp.fromDate(new Date(event.creationTime)) : undefined,
Expand Down
2 changes: 2 additions & 0 deletions components/server/src/auth/host-context-provider-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { HostContainerMapping } from "./host-container-mapping";
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { repeat } from "@gitpod/gitpod-protocol/lib/util/repeat";
import { RepositoryService } from "../repohost/repo-service";

@injectable()
export class HostContextProviderImpl implements HostContextProvider {
Expand Down Expand Up @@ -152,6 +153,7 @@ export class HostContextProviderImpl implements HostContextProvider {
const container = parentContainer.createChild();
container.bind(AuthProviderParams).toConstantValue(authProviderConfig);
container.bind(HostContext).toSelf().inSingletonScope();
container.bind(RepositoryService).toSelf().inSingletonScope();

const hostContainerMapping = parentContainer.get(HostContainerMapping);
const containerModules = hostContainerMapping.get(authProviderConfig.type);
Expand Down
3 changes: 3 additions & 0 deletions components/server/src/github/github-container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { GithubRepositoryProvider } from "./github-repository-provider";
import { GitHubTokenHelper } from "./github-token-helper";
import { IGitTokenValidator } from "../workspace/git-token-validator";
import { GitHubTokenValidator } from "./github-token-validator";
import { RepositoryService } from "../repohost/repo-service";
import { GitHubService } from "../prebuilds/github-service";

export const githubContainerModule = new ContainerModule((bind, _unbind, _isBound, rebind) => {
bind(RepositoryHost).toSelf().inSingletonScope();
Expand All @@ -32,4 +34,5 @@ export const githubContainerModule = new ContainerModule((bind, _unbind, _isBoun
bind(GitHubTokenHelper).toSelf().inSingletonScope();
bind(GitHubTokenValidator).toSelf().inSingletonScope();
bind(IGitTokenValidator).toService(GitHubTokenValidator);
rebind(RepositoryService).to(GitHubService).inSingletonScope();
});
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();
}
}
106 changes: 106 additions & 0 deletions components/server/src/prebuilds/github-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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 { RepoURL } from "../repohost";
import { UnauthorizedError } from "../errors";
import { GitHubOAuthScopes } from "@gitpod/public-api-common/lib/auth-providers";
import { containsScopes } from "./token-scopes-inclusion";
import { TokenService } from "../user/token-service";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";

@injectable()
export class GitHubService extends RepositoryService {
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 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;
}
}

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, "prebuild", 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.PRIVATE_REPO,
providerIsConnected: true,
isMissingScopes: containsScopes(
tokenEntry?.token.scopes,
GitHubOAuthScopes.Requirements.PRIVATE_REPO,
),
});
}
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();
}
}
Loading
Loading