Skip to content

Commit 34dfbe2

Browse files
committed
Proper webhook installed detection
1 parent 2aba460 commit 34dfbe2

File tree

8 files changed

+318
-6
lines changed

8 files changed

+318
-6
lines changed

components/gitpod-protocol/src/protocol.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,10 @@ export interface Repository {
12821282
// The direct parent of this fork
12831283
parent: Repository;
12841284
};
1285+
/**
1286+
* Optional date when the repository was last pushed to.
1287+
*/
1288+
pushedAt?: string;
12851289
}
12861290

12871291
export interface RepositoryInfo {

components/server/src/github/github-repository-provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export class GithubRepositoryProvider implements RepositoryProvider {
2626
const avatarUrl = repository.owner.avatar_url;
2727
const webUrl = repository.html_url;
2828
const defaultBranch = repository.default_branch;
29-
return { host, owner, name: repo, cloneUrl, description, avatarUrl, webUrl, defaultBranch };
29+
const pushedAt = repository.pushed_at;
30+
return { host, owner, name: repo, cloneUrl, description, avatarUrl, webUrl, defaultBranch, pushedAt };
3031
}
3132

3233
async getBranch(user: User, owner: string, repo: string, branch: string): Promise<Branch> {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { RepositoryService } from "../repohost/repo-service";
8+
import { User } from "@gitpod/gitpod-protocol";
9+
import { inject, injectable } from "inversify";
10+
import { BitbucketServerApi } from "../bitbucket-server/bitbucket-server-api";
11+
import { BitbucketServerContextParser } from "../bitbucket-server/bitbucket-server-context-parser";
12+
import { Config } from "../config";
13+
import { BitbucketServerApp } from "./bitbucket-server-app";
14+
15+
@injectable()
16+
export class BitbucketServerService extends RepositoryService {
17+
constructor(
18+
@inject(BitbucketServerApi) private readonly api: BitbucketServerApi,
19+
@inject(Config) private readonly config: Config,
20+
@inject(BitbucketServerContextParser) private readonly contextParser: BitbucketServerContextParser,
21+
) {
22+
super();
23+
}
24+
25+
public async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
26+
try {
27+
const { owner, repoName, repoKind } = await this.contextParser.parseURL(user, cloneUrl);
28+
const existing = await this.api.getWebhooks(user, {
29+
repoKind,
30+
repositorySlug: repoName,
31+
owner,
32+
});
33+
if (!existing.values) {
34+
return false;
35+
}
36+
const hookUrl = this.getHookUrl();
37+
38+
return existing.values.some((hook) => hook.url && hook.url.includes(hookUrl));
39+
} catch (error) {
40+
console.error("Failed to check if Gitpod webhook is enabled.", error, { cloneUrl });
41+
42+
return false;
43+
}
44+
}
45+
46+
protected getHookUrl() {
47+
return this.config.hostUrl
48+
.asPublicServices()
49+
.with({
50+
pathname: BitbucketServerApp.path,
51+
})
52+
.toString();
53+
}
54+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { RepositoryService } from "../repohost/repo-service";
8+
import { User } from "@gitpod/gitpod-protocol";
9+
import { inject, injectable } from "inversify";
10+
import { BitbucketApiFactory } from "../bitbucket/bitbucket-api-factory";
11+
import { BitbucketApp } from "./bitbucket-app";
12+
import { Config } from "../config";
13+
import { BitbucketContextParser } from "../bitbucket/bitbucket-context-parser";
14+
15+
@injectable()
16+
export class BitbucketService extends RepositoryService {
17+
constructor(
18+
@inject(BitbucketApiFactory) private readonly api: BitbucketApiFactory,
19+
@inject(Config) private readonly config: Config,
20+
@inject(BitbucketContextParser) private readonly bitbucketContextParser: BitbucketContextParser,
21+
) {
22+
super();
23+
}
24+
25+
public async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
26+
try {
27+
const api = await this.api.create(user);
28+
const { owner, repoName } = await this.bitbucketContextParser.parseURL(user, cloneUrl);
29+
const hooks = await api.repositories.listWebhooks({
30+
repo_slug: repoName,
31+
workspace: owner,
32+
});
33+
if (!hooks.data.values) {
34+
return false;
35+
}
36+
return hooks.data.values.some((hook) => hook.url === this.getHookUrl());
37+
} catch (error) {
38+
console.error("Failed to check if Gitpod webhook is enabled.", error, { cloneUrl });
39+
40+
return false;
41+
}
42+
}
43+
44+
protected getHookUrl() {
45+
return this.config.hostUrl
46+
.asPublicServices()
47+
.with({
48+
pathname: BitbucketApp.path,
49+
})
50+
.toString();
51+
}
52+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { RepositoryService } from "../repohost/repo-service";
8+
import { inject, injectable } from "inversify";
9+
import { GitHubApiError, GitHubRestApi } from "../github/api";
10+
import { GitHubEnterpriseApp } from "./github-enterprise-app";
11+
import { GithubContextParser } from "../github/github-context-parser";
12+
import { User } from "@gitpod/gitpod-protocol";
13+
import { Config } from "../config";
14+
import { TokenService } from "../user/token-service";
15+
import { RepoURL } from "../repohost";
16+
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
17+
import { UnauthorizedError } from "../errors";
18+
import { containsScopes } from "./token-scopes-inclusion";
19+
import { GitHubOAuthScopes } from "@gitpod/public-api-common/lib/auth-providers";
20+
21+
@injectable()
22+
export class GitHubService extends RepositoryService {
23+
static PREBUILD_TOKEN_SCOPE = "prebuilds";
24+
25+
constructor(
26+
@inject(GitHubRestApi) protected readonly githubApi: GitHubRestApi,
27+
@inject(Config) private readonly config: Config,
28+
@inject(TokenService) private readonly tokenService: TokenService,
29+
@inject(GithubContextParser) private readonly githubContextParser: GithubContextParser,
30+
) {
31+
super();
32+
}
33+
34+
async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> {
35+
const parsedRepoUrl = RepoURL.parseRepoUrl(cloneUrl);
36+
if (!parsedRepoUrl) {
37+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, `Clone URL not parseable.`);
38+
}
39+
let tokenEntry;
40+
try {
41+
const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
42+
const webhooks = (await this.githubApi.run(user, (gh) => gh.repos.listWebhooks({ owner, repo }))).data;
43+
for (const webhook of webhooks) {
44+
if (webhook.config.url === this.getHookUrl()) {
45+
await this.githubApi.run(user, (gh) =>
46+
gh.repos.deleteWebhook({ owner, repo, hook_id: webhook.id }),
47+
);
48+
}
49+
}
50+
tokenEntry = await this.tokenService.createGitpodToken(user, GitHubService.PREBUILD_TOKEN_SCOPE, cloneUrl);
51+
const config = {
52+
url: this.getHookUrl(),
53+
content_type: "json",
54+
secret: user.id + "|" + tokenEntry.token.value,
55+
};
56+
await this.githubApi.run(user, (gh) => gh.repos.createWebhook({ owner, repo, config }));
57+
} catch (error) {
58+
// Hint: here we catch all GH API errors to forward them as Unauthorized to FE,
59+
// eventually that should be done depending on the error code.
60+
// Also, if user is not connected at all, then the GH API wrapper is throwing
61+
// the same error type, but with `providerIsConnected: false`.
62+
63+
if (GitHubApiError.is(error)) {
64+
// TODO check for `error.code`
65+
throw UnauthorizedError.create({
66+
host: parsedRepoUrl.host,
67+
providerType: "GitHub",
68+
repoName: parsedRepoUrl.repo,
69+
requiredScopes: GitHubOAuthScopes.Requirements.DEFAULT,
70+
providerIsConnected: true,
71+
isMissingScopes: containsScopes(
72+
tokenEntry?.token.scopes,
73+
GitHubOAuthScopes.Requirements.PRIVATE_REPO,
74+
),
75+
});
76+
}
77+
throw error;
78+
}
79+
}
80+
81+
async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
82+
try {
83+
const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
84+
const webhooks = (await this.githubApi.run(user, (gh) => gh.repos.listWebhooks({ owner, repo }))).data;
85+
return webhooks.some((webhook) => webhook.config.url === this.getHookUrl());
86+
} catch (error) {
87+
if (GitHubApiError.is(error)) {
88+
throw UnauthorizedError.create({
89+
host: RepoURL.parseRepoUrl(cloneUrl)!.host,
90+
providerType: "GitHub",
91+
repoName: RepoURL.parseRepoUrl(cloneUrl)!.repo,
92+
requiredScopes: GitHubOAuthScopes.Requirements.DEFAULT,
93+
providerIsConnected: true,
94+
});
95+
}
96+
throw error;
97+
}
98+
}
99+
100+
protected getHookUrl() {
101+
return this.config.hostUrl
102+
.asPublicServices()
103+
.with({
104+
pathname: GitHubEnterpriseApp.path,
105+
})
106+
.toString();
107+
}
108+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { RepositoryService } from "../repohost/repo-service";
8+
import { User } from "@gitpod/gitpod-protocol";
9+
import { inject, injectable } from "inversify";
10+
import { GitLabApi, GitLab } from "../gitlab/api";
11+
import { GitLabApp } from "./gitlab-app";
12+
import { Config } from "../config";
13+
import { GitlabContextParser } from "../gitlab/gitlab-context-parser";
14+
import { RepoURL } from "../repohost";
15+
import { UnauthorizedError } from "../errors";
16+
import { GitLabOAuthScopes } from "@gitpod/public-api-common/lib/auth-providers";
17+
18+
@injectable()
19+
export class GitlabService extends RepositoryService {
20+
constructor(
21+
@inject(GitLabApi) protected api: GitLabApi,
22+
@inject(Config) private readonly config: Config,
23+
@inject(GitlabContextParser) private readonly gitlabContextParser: GitlabContextParser,
24+
) {
25+
super();
26+
}
27+
28+
public async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
29+
try {
30+
const { owner, repoName } = await this.gitlabContextParser.parseURL(user, cloneUrl);
31+
const hooks = (await this.api.run(user, (g) =>
32+
g.ProjectHooks.all(`${owner}/${repoName}`),
33+
)) as unknown as GitLab.ProjectHook[];
34+
return hooks.some((hook) => hook.url === this.getHookUrl());
35+
} catch (error) {
36+
if (GitLab.ApiError.is(error)) {
37+
throw UnauthorizedError.create({
38+
host: RepoURL.parseRepoUrl(cloneUrl)!.host,
39+
providerType: "GitLab",
40+
repoName: RepoURL.parseRepoUrl(cloneUrl)!.repo,
41+
requiredScopes: GitLabOAuthScopes.Requirements.REPO,
42+
providerIsConnected: true,
43+
});
44+
}
45+
throw error;
46+
}
47+
}
48+
49+
private getHookUrl() {
50+
return this.config.hostUrl
51+
.asPublicServices()
52+
.with({
53+
pathname: GitLabApp.path,
54+
})
55+
.toString();
56+
}
57+
}

components/server/src/projects/projects-service.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
FindPrebuildsParams,
1414
Project,
1515
User,
16-
WebhookEvent,
1716
CommitContext,
17+
WebhookEvent,
1818
} from "@gitpod/gitpod-protocol";
1919
import { HostContextProvider } from "../auth/host-context-provider";
2020
import { RepoURL } from "../repohost";
@@ -38,6 +38,7 @@ import { IDEService } from "../ide-service";
3838
import type { PrebuildManager } from "../prebuilds/prebuild-manager";
3939
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
4040
import { ContextParser } from "../workspace/context-parser-service";
41+
import { UnauthorizedError } from "../errors";
4142

4243
// to resolve circular dependency issues
4344
export const LazyPrebuildManager = Symbol("LazyPrebuildManager");
@@ -55,8 +56,8 @@ export class ProjectsService {
5556
@inject(Authorizer) private readonly auth: Authorizer,
5657
@inject(IDEService) private readonly ideService: IDEService,
5758
@inject(LazyPrebuildManager) private readonly prebuildManager: LazyPrebuildManager,
59+
@inject(ContextParser) private readonly contextParser: ContextParser,
5860
@inject(WebhookEventDB) private readonly webhookEventDb: WebhookEventDB,
59-
@inject(ContextParser) private contextParser: ContextParser,
6061

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

557-
const events = await this.webhookEventDb.findByCloneUrl(project.cloneUrl, 1);
558+
const events = await this.webhookEventDb.findByCloneUrl(project.cloneUrl, 50);
558559
if (events.length === 0) {
559560
return undefined;
560561
}
561562

562563
const hostContext = this.hostContextProvider.get(context.repository.host);
563-
const repoProvider = hostContext?.services?.repositoryProvider;
564-
if (!repoProvider) {
564+
const repoService = hostContext?.services?.repositoryService;
565+
if (!repoService) {
565566
throw new ApplicationError(ErrorCodes.INTERNAL_SERVER_ERROR, `repo provider unavailable`);
566567
}
568+
569+
try {
570+
const webhookEnabled = await repoService.isGitpodWebhookEnabled(user, project.cloneUrl);
571+
return webhookEnabled ? events[0] : undefined; // todo(ft): figure out what to return if webhook is enabled but we have no events
572+
} catch (error) {
573+
if (!UnauthorizedError.is(error) && error.message !== "unsupported") {
574+
throw error;
575+
}
576+
}
577+
567578
const matchingEvent = events.find((event) => {
568579
if (maxAge && Date.now() - new Date(event.creationTime).getTime() > maxAge) {
569580
return false;
570581
}
571582

583+
// If we know when the source repository was last pushed to, we can figure out if we received the push event after that
584+
if (context.repository.pushedAt && new Date(event.creationTime) >= new Date(context.repository.pushedAt)) {
585+
return true;
586+
}
587+
588+
// If we know the commit hash, we can check if latest commit in the event matches the one we know.
589+
// We do this check second, because pushing to the non-default branch might throw this off.
572590
return context.revision === event.commit;
573591
});
574592

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Copyright (c) 2020 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { User } from "@gitpod/gitpod-protocol";
8+
import { injectable } from "inversify";
9+
10+
@injectable()
11+
export class RepositoryService {
12+
async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> {
13+
throw new Error("unsupported");
14+
}
15+
async isGitpodWebhookEnabled(user: User, cloneUrl: string): Promise<boolean> {
16+
throw new Error("unsupported");
17+
}
18+
}

0 commit comments

Comments
 (0)