diff --git a/components/dashboard/src/images/gitea.svg b/components/dashboard/src/images/gitea.svg
new file mode 100644
index 00000000000000..69ae54daf26ccc
--- /dev/null
+++ b/components/dashboard/src/images/gitea.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx
index 98fe5d491feebb..8d840eb8f81f43 100644
--- a/components/dashboard/src/provider-utils.tsx
+++ b/components/dashboard/src/provider-utils.tsx
@@ -5,9 +5,10 @@
*/
import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
-import bitbucket from "./images/bitbucket.svg";
-import github from "./images/github.svg";
-import gitlab from "./images/gitlab.svg";
+import bitbucket from './images/bitbucket.svg';
+import github from './images/github.svg';
+import gitlab from './images/gitlab.svg';
+import gitea from './images/gitea.svg';
import { gitpodHostUrl } from "./service/service";
function iconForAuthProvider(type: string | AuthProviderType) {
@@ -18,6 +19,8 @@ function iconForAuthProvider(type: string | AuthProviderType) {
case "GitLab":
case AuthProviderType.GITLAB:
return ;
+ case "Gitea":
+ return ;
case "Bitbucket":
case AuthProviderType.BITBUCKET:
return ;
diff --git a/components/dashboard/src/user-settings/Integrations.tsx b/components/dashboard/src/user-settings/Integrations.tsx
index aaadd3e9c9b720..bf5c4669d3dc66 100644
--- a/components/dashboard/src/user-settings/Integrations.tsx
+++ b/components/dashboard/src/user-settings/Integrations.tsx
@@ -489,7 +489,7 @@ function GitIntegrations() {
Git Integrations
- Manage Git integrations for self-managed instances of GitLab, GitHub, or Bitbucket.
+ Manage Git integrations for self-managed instances of GitLab, GitHub, Gitea or Bitbucket.
{/* Hide create button if ff is disabled */}
@@ -714,6 +714,9 @@ export function GitIntegrationModal(
settingsUrl = `${host}/-/profile/applications`;
}
break;
+ case "Gitea":
+ settingsUrl = `${host}/user/settings/applications`;
+ break;
default:
return undefined;
}
@@ -725,6 +728,9 @@ export function GitIntegrationModal(
case AuthProviderType.GITLAB:
docsUrl = `https://www.gitpod.io/docs/gitlab-integration/#oauth-application`;
break;
+ case "Gitea":
+ docsUrl = `https://www.gitpod.io/docs/gitea-integration/#oauth-application`;
+ break;
default:
return undefined;
}
@@ -787,7 +793,7 @@ export function GitIntegrationModal(
{props.headerText ||
- "Configure an integration with a self-managed instance of GitLab, GitHub, or Bitbucket."}
+ "Configure an integration with a self-managed instance of GitLab, GitHub, Bitbucket or Gitea."}
@@ -806,6 +812,7 @@ export function GitIntegrationModal(
>
+
diff --git a/components/server/ee/src/gitea/container-module.ts b/components/server/ee/src/gitea/container-module.ts
new file mode 100644
index 00000000000000..391e0eedbc343a
--- /dev/null
+++ b/components/server/ee/src/gitea/container-module.ts
@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) 2020 Gitpod GmbH. All rights reserved.
+ * Licensed under the Gitpod Enterprise Source Code License,
+ * See License.enterprise.txt in the project root folder.
+ */
+
+import { ContainerModule } from "inversify";
+import { GiteaService } from "../prebuilds/gitea-service";
+import { RepositoryService } from "../../../src/repohost/repo-service";
+
+export const giteaContainerModuleEE = new ContainerModule((_bind, _unbind, _isBound, rebind) => {
+ rebind(RepositoryService).to(GiteaService).inSingletonScope();
+});
diff --git a/components/server/ee/src/gitea/gitea-app-support.ts b/components/server/ee/src/gitea/gitea-app-support.ts
new file mode 100644
index 00000000000000..cd756aabf1027a
--- /dev/null
+++ b/components/server/ee/src/gitea/gitea-app-support.ts
@@ -0,0 +1,58 @@
+/**
+ * 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 { AuthProviderInfo, ProviderRepository, User } from "@gitpod/gitpod-protocol";
+import { inject, injectable } from "inversify";
+import { TokenProvider } from "../../../src/user/token-provider";
+import { UserDB } from "@gitpod/gitpod-db/lib";
+import { Gitea } from "../../../src/gitea/api";
+
+@injectable()
+export class GiteaAppSupport {
+
+ @inject(UserDB) protected readonly userDB: UserDB;
+ @inject(TokenProvider) protected readonly tokenProvider: TokenProvider;
+
+ async getProviderRepositoriesForUser(params: { user: User, provider: AuthProviderInfo }): Promise {
+ const token = await this.tokenProvider.getTokenForHost(params.user, params.provider.host);
+ const oauthToken = token.value;
+ const api = Gitea.create(`https://${params.provider.host}`, oauthToken);
+
+ const result: ProviderRepository[] = [];
+ const ownersRepos: ProviderRepository[] = [];
+
+ const identity = params.user.identities.find(i => i.authProviderId === params.provider.authProviderId);
+ if (!identity) {
+ return result;
+ }
+ const usersAccount = identity.authName;
+
+ // TODO: check if valid
+ const projectsWithAccess = await api.user.userCurrentListRepos({ limit: 100 });
+ for (const project of projectsWithAccess.data) {
+ const path = project.full_name as string;
+ const cloneUrl = project.clone_url as string;
+ const updatedAt = project.updated_at as string;
+ const accountAvatarUrl = project.owner?.avatar_url as string;
+ const account = project.owner?.login as string;
+
+ (account === usersAccount ? ownersRepos : result).push({
+ name: project.name as string,
+ path,
+ account,
+ cloneUrl,
+ updatedAt,
+ accountAvatarUrl,
+ // inUse: // todo(at) compute usage via ProjectHooks API
+ })
+ }
+
+ // put owner's repos first. the frontend will pick first account to continue with
+ result.unshift(...ownersRepos);
+ return result;
+ }
+
+}
\ No newline at end of file
diff --git a/components/server/package.json b/components/server/package.json
index d929a3f0ea1cee..0d7cc19cc34f1f 100644
--- a/components/server/package.json
+++ b/components/server/package.json
@@ -74,12 +74,14 @@
"cookie": "^0.4.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.4",
+ "cross-fetch": "^3.1.5",
"countries-list": "^2.6.1",
"deep-equal": "^2.0.5",
"deepmerge": "^4.2.2",
"express": "^4.17.3",
"express-http-proxy": "^1.0.7",
"fs-extra": "^10.0.0",
+ "gitea-js": "^1.20.1",
"google-protobuf": "^3.19.1",
"inversify": "^6.0.1",
"ioredis": "^5.3.2",
diff --git a/components/server/src/auth/auth-provider-service.ts b/components/server/src/auth/auth-provider-service.ts
index 851308766d5a28..a896e6cd424782 100644
--- a/components/server/src/auth/auth-provider-service.ts
+++ b/components/server/src/auth/auth-provider-service.ts
@@ -12,6 +12,7 @@ import { Config } from "../config";
import { v4 as uuidv4 } from "uuid";
import { oauthUrls as githubUrls } from "../github/github-urls";
import { oauthUrls as gitlabUrls } from "../gitlab/gitlab-urls";
+import { oauthUrls as giteaUrls } from "../gitea/gitea-urls";
import { oauthUrls as bbsUrls } from "../bitbucket-server/bitbucket-server-urls";
import { oauthUrls as bbUrls } from "../bitbucket/bitbucket-urls";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
@@ -335,6 +336,9 @@ export class AuthProviderService {
case "BitbucketServer":
urls = bbsUrls(host);
break;
+ case "Gitea":
+ urls = giteaUrls(host);
+ break;
case "Bitbucket":
urls = bbUrls(host);
break;
diff --git a/components/server/src/auth/host-container-mapping.ts b/components/server/src/auth/host-container-mapping.ts
index 37933287b1045d..feb3777f075b09 100644
--- a/components/server/src/auth/host-container-mapping.ts
+++ b/components/server/src/auth/host-container-mapping.ts
@@ -9,6 +9,7 @@ import { githubContainerModule } from "../github/github-container-module";
import { gitlabContainerModule } from "../gitlab/gitlab-container-module";
import { genericAuthContainerModule } from "./oauth-container-module";
import { bitbucketContainerModule } from "../bitbucket/bitbucket-container-module";
+import { giteaContainerModule } from "../gitea/gitea-container-module";
import { bitbucketServerContainerModule } from "../bitbucket-server/bitbucket-server-container-module";
@injectable()
@@ -19,6 +20,8 @@ export class HostContainerMapping {
return [githubContainerModule];
case "GitLab":
return [gitlabContainerModule];
+ case "Gitea":
+ return [giteaContainerModule];
case "OAuth":
return [genericAuthContainerModule];
case "Bitbucket":
diff --git a/components/server/src/dev/dev-data.ts b/components/server/src/dev/dev-data.ts
index 95360a56a14762..d2c2becb7809dc 100644
--- a/components/server/src/dev/dev-data.ts
+++ b/components/server/src/dev/dev-data.ts
@@ -73,6 +73,13 @@ export namespace DevData {
return result;
}
+ export function createGiteaTestToken(): Token {
+ return {
+ ...getTokenFromEnv("GITPOD_TEST_TOKEN_GITEA"),
+ scopes: [],
+ };
+ }
+
function getTokenFromEnv(varname: string): Token {
const secret = process.env[varname];
if (!secret) {
diff --git a/components/server/src/gitea/api.ts b/components/server/src/gitea/api.ts
new file mode 100644
index 00000000000000..fa427ff46d8ba3
--- /dev/null
+++ b/components/server/src/gitea/api.ts
@@ -0,0 +1,105 @@
+/**
+ * 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 {
+ giteaApi,
+ Api,
+ Commit as ICommit,
+ Repository as IRepository,
+ ContentsResponse as IContentsResponse,
+ Branch as IBranch,
+ Tag as ITag,
+ PullRequest as IPullRequest,
+ Issue as IIssue,
+ User as IUser,
+} from "gitea-js";
+import fetch from "cross-fetch";
+
+import { User } from "@gitpod/gitpod-protocol";
+import { injectable, inject } from "inversify";
+import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
+import { GiteaScope } from "./scopes";
+import { AuthProviderParams } from "../auth/auth-provider";
+import { GiteaTokenHelper } from "./gitea-token-helper";
+
+export namespace Gitea {
+ export class ApiError extends Error {
+ readonly httpError: { name: string; description: string } | undefined;
+ constructor(msg?: string, httpError?: any) {
+ super(msg);
+ this.httpError = httpError;
+ this.name = "GiteaApiError";
+ }
+ }
+ export namespace ApiError {
+ export function is(something: any): something is ApiError {
+ return !!something && something.name === "GiteaApiError";
+ }
+ export function isNotFound(error: ApiError): boolean {
+ return !!error.httpError?.description.startsWith("404");
+ }
+ export function isInternalServerError(error: ApiError): boolean {
+ return !!error.httpError?.description.startsWith("500");
+ }
+ }
+
+ export function create(host: string, token: string) {
+ return giteaApi(`https://${host}`, {
+ customFetch: fetch,
+ token,
+ });
+ }
+
+ export type Commit = ICommit;
+ export type Repository = IRepository;
+ export type ContentsResponse = IContentsResponse;
+ export type Branch = IBranch;
+ export type Tag = ITag;
+ export type PullRequest = IPullRequest;
+ export type Issue = IIssue;
+ export type User = IUser;
+}
+
+@injectable()
+export class GiteaRestApi {
+ @inject(AuthProviderParams) readonly config: AuthProviderParams;
+ @inject(GiteaTokenHelper) protected readonly tokenHelper: GiteaTokenHelper;
+ protected async create(userOrToken: User | string) {
+ let oauthToken: string | undefined;
+ if (typeof userOrToken === "string") {
+ oauthToken = userOrToken;
+ } else {
+ const giteaToken = await this.tokenHelper.getTokenWithScopes(userOrToken, GiteaScope.Requirements.DEFAULT);
+ oauthToken = giteaToken.value;
+ }
+ const api = Gitea.create(this.config.host, oauthToken);
+ return api;
+ }
+
+ public async run(
+ userOrToken: User | string,
+ operation: (g: Api) => Promise,
+ ): Promise {
+ const before = new Date().getTime();
+ const userApi = await this.create(userOrToken);
+ try {
+ const response = (await operation(userApi)) as R;
+ return response as R;
+ } catch (error) {
+ if (error && error?.type === "system") {
+ return new Gitea.ApiError(`Gitea Fetch Error: ${error?.message}`, error);
+ }
+ if (error?.error && !error?.data && error?.error?.errors) {
+ return new Gitea.ApiError(`Gitea Api Error: ${error?.error?.message}`, error?.error);
+ }
+
+ // log.error(`Gitea request error`, error);
+ throw error;
+ } finally {
+ log.info(`Gitea request took ${new Date().getTime() - before} ms`);
+ }
+ }
+}
diff --git a/components/server/src/gitea/convert.ts b/components/server/src/gitea/convert.ts
new file mode 100644
index 00000000000000..8d75823bdb034d
--- /dev/null
+++ b/components/server/src/gitea/convert.ts
@@ -0,0 +1,34 @@
+/**
+ * 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 } from "@gitpod/gitpod-protocol";
+import { RepoURL } from "../repohost";
+import { Gitea } from "./api";
+
+export function convertRepo(repo: Gitea.Repository): Repository | undefined {
+ if (!repo.clone_url || !repo.name || !repo.owner?.login) {
+ return undefined;
+ }
+
+ const host = RepoURL.parseRepoUrl(repo.clone_url)!.host;
+
+ return {
+ host,
+ owner: repo.owner.login,
+ name: repo.name,
+ cloneUrl: repo.clone_url,
+ description: repo.description,
+ avatarUrl: repo.avatar_url,
+ webUrl: repo.html_url, // TODO: is this correct?
+ defaultBranch: repo.default_branch,
+ private: repo.private,
+ fork: undefined, // TODO: load fork somehow
+ };
+}
+
+// export function convertBranch(repo: Gitea.Repository): Branch | undefined {
+
+// }
diff --git a/components/server/src/gitea/file-provider.ts b/components/server/src/gitea/file-provider.ts
new file mode 100644
index 00000000000000..ae028c0bf840d0
--- /dev/null
+++ b/components/server/src/gitea/file-provider.ts
@@ -0,0 +1,57 @@
+/**
+ * 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 { injectable, inject } from 'inversify';
+
+import { FileProvider, MaybeContent } from "../repohost/file-provider";
+import { Commit, User, Repository } from "@gitpod/gitpod-protocol"
+import { Gitea, GiteaRestApi } from "./api";
+
+@injectable()
+export class GiteaFileProvider implements FileProvider {
+
+ @inject(GiteaRestApi) protected readonly giteaApi: GiteaRestApi;
+
+ public async getGitpodFileContent(commit: Commit, user: User): Promise {
+ 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(repository: Repository, revisionOrBranch: string, user: User, path: string): Promise {
+ const commits = (await this.giteaApi.run(user, (api) => api.repos.repoGetAllCommits(repository.owner, repository.name, {
+ sha: revisionOrBranch,
+ limit: 1, // we need just the last one right?
+ path
+ })));
+
+ if (Gitea.ApiError.is(commits) || commits.length === 0) {
+ throw new Error(`File ${path} does not exist in repository ${repository.owner}/${repository.name}`);
+ }
+
+ const sha = commits[0].sha;
+ if (!sha) {
+ throw new Error(`File ${path} in repository ${repository.owner}/${repository.name} has no char. Is it a folder?`);
+ }
+
+ return sha;
+ }
+
+ public async getFileContent(commit: Commit, user: User, path: string): Promise {
+ if (!commit.revision) {
+ return undefined;
+ }
+
+ const contents = await this.giteaApi.run(user, api => api.repos.repoGetRawFile(commit.repository.owner, commit.repository.name, path, { ref: commit.revision }))
+ if (Gitea.ApiError.is(contents)) {
+ return undefined; // e.g. 404 error, because the file isn't found
+ }
+
+ return contents;
+ }
+}
diff --git a/components/server/src/gitea/gitea-auth-provider.ts b/components/server/src/gitea/gitea-auth-provider.ts
new file mode 100644
index 00000000000000..b9c7b10a4fc441
--- /dev/null
+++ b/components/server/src/gitea/gitea-auth-provider.ts
@@ -0,0 +1,105 @@
+/**
+ * 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 * as express from "express";
+ import { injectable } from 'inversify';
+ import { log } from '@gitpod/gitpod-protocol/lib/util/logging';
+ import { AuthProviderInfo } from '@gitpod/gitpod-protocol';
+ import { GiteaScope } from "./scopes";
+ import { UnconfirmedUserException } from "../auth/errors";
+ import { Gitea } from "./api";
+ import { GenericAuthProvider } from "../auth/generic-auth-provider";
+ import { AuthUserSetup } from "../auth/auth-provider";
+ import { oauthUrls } from "./gitea-urls";
+
+ @injectable()
+ export class GiteaAuthProvider extends GenericAuthProvider {
+
+
+ get info(): AuthProviderInfo {
+ return {
+ ...this.defaultInfo(),
+ scopes: GiteaScope.All,
+ requirements: {
+ default: GiteaScope.Requirements.DEFAULT,
+ publicRepo: GiteaScope.Requirements.PUBLIC_REPO,
+ privateRepo: GiteaScope.Requirements.PRIVATE_REPO,
+ },
+ }
+ }
+
+ /**
+ * Augmented OAuthConfig for Gitea
+ */
+ protected get oauthConfig() {
+ const oauth = this.params.oauth!;
+ const defaultUrls = oauthUrls(this.params.host);
+ const scopeSeparator = " ";
+ return {
+ ...oauth,
+ authorizationUrl: oauth.authorizationUrl || defaultUrls.authorizationUrl,
+ tokenUrl: oauth.tokenUrl || defaultUrls.tokenUrl,
+ settingsUrl: oauth.settingsUrl || defaultUrls.settingsUrl,
+ scope: GiteaScope.All.join(scopeSeparator),
+ scopeSeparator
+ };
+ }
+
+ authorize(req: express.Request, res: express.Response, next: express.NextFunction, scope?: string[]): void {
+ super.authorize(req, res, next, scope ? scope : GiteaScope.Requirements.DEFAULT);
+ }
+
+ protected readAuthUserSetup = async (accessToken: string, tokenResponse: object) => {
+ const api = Gitea.create(this.params.host, accessToken);
+ const getCurrentUser = async () => {
+ const response = await api.user.userGetCurrent();
+ return response.data as unknown as Gitea.User;
+ }
+ try {
+ const result = await getCurrentUser();
+ if (result) {
+ if (!result.active || result.prohibit_login) {
+ throw UnconfirmedUserException.create("Please confirm and activate your Gitea account and try again.", result);
+ }
+ }
+
+ return {
+ authUser: {
+ authId: String(result.id),
+ authName: result.login,
+ avatarUrl: result.avatar_url || undefined,
+ name: result.full_name,
+ primaryEmail: result.email
+ },
+ currentScopes: this.readScopesFromVerifyParams(tokenResponse)
+ }
+ } catch (error) {
+ // TODO: cleanup & check for Gitea instead of Gitlab
+ // if (error && typeof error.description === "string" && error.description.includes("403 Forbidden")) {
+ // // If Gitlab is configured to disallow OAuth-token based API access for unconfirmed users, we need to reject this attempt
+ // // 403 Forbidden - You (@...) must accept the Terms of Service in order to perform this action. Please access GitLab from a web browser to accept these terms.
+ // throw UnconfirmedUserException.create(error.description, error);
+ // } else {
+ log.error(`(${this.strategyName}) Reading current user info failed`, error, { accessToken, error });
+ throw error;
+ // }
+ }
+
+ }
+
+ protected readScopesFromVerifyParams(params: any) {
+ if (params && typeof params.scope === 'string') {
+ return this.normalizeScopes(params.scope.split(' '));
+ }
+ return [];
+ }
+
+ protected normalizeScopes(scopes: string[]) {
+ const set = new Set(scopes);
+ return Array.from(set).sort();
+ }
+
+ }
diff --git a/components/server/src/gitea/gitea-container-module.ts b/components/server/src/gitea/gitea-container-module.ts
new file mode 100644
index 00000000000000..51eb97b3d9f055
--- /dev/null
+++ b/components/server/src/gitea/gitea-container-module.ts
@@ -0,0 +1,37 @@
+/**
+ * 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 { ContainerModule } from "inversify";
+import { AuthProvider } from "../auth/auth-provider";
+import { FileProvider, LanguagesProvider, RepositoryProvider, RepositoryHost } from "../repohost";
+import { IContextParser } from "../workspace/context-parser";
+import { GiteaRestApi } from "./api";
+import { GiteaFileProvider } from "./file-provider";
+import { GiteaAuthProvider } from "./gitea-auth-provider";
+import { GiteaContextParser } from "./gitea-context-parser";
+import { GiteaRepositoryProvider } from "./gitea-repository-provider";
+import { GiteaTokenHelper } from "./gitea-token-helper";
+import { GiteaLanguagesProvider } from "./languages-provider";
+import { IGitTokenValidator } from "../workspace/git-token-validator";
+import { GiteaTokenValidator } from "./gitea-token-validator";
+
+export const giteaContainerModule = new ContainerModule((bind, _unbind, _isBound, _rebind) => {
+ bind(RepositoryHost).toSelf().inSingletonScope();
+ bind(GiteaRestApi).toSelf().inSingletonScope();
+ bind(GiteaFileProvider).toSelf().inSingletonScope();
+ bind(FileProvider).toService(GiteaFileProvider);
+ bind(GiteaAuthProvider).toSelf().inSingletonScope();
+ bind(AuthProvider).toService(GiteaAuthProvider);
+ bind(GiteaLanguagesProvider).toSelf().inSingletonScope();
+ bind(LanguagesProvider).toService(GiteaLanguagesProvider);
+ bind(GiteaRepositoryProvider).toSelf().inSingletonScope();
+ bind(RepositoryProvider).toService(GiteaRepositoryProvider);
+ bind(GiteaContextParser).toSelf().inSingletonScope();
+ bind(IContextParser).toService(GiteaContextParser);
+ bind(GiteaTokenHelper).toSelf().inSingletonScope();
+ bind(GiteaTokenValidator).toSelf().inSingletonScope();
+ bind(IGitTokenValidator).toService(GiteaTokenValidator);
+});
diff --git a/components/server/src/gitea/gitea-context-parser.spec.ts b/components/server/src/gitea/gitea-context-parser.spec.ts
new file mode 100644
index 00000000000000..0c3aa14d473d65
--- /dev/null
+++ b/components/server/src/gitea/gitea-context-parser.spec.ts
@@ -0,0 +1,617 @@
+/**
+ * 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.
+ */
+
+// Use asyncIterators with es2015
+if (typeof (Symbol as any).asyncIterator === "undefined") {
+ (Symbol as any).asyncIterator = Symbol.asyncIterator || Symbol("asyncIterator");
+}
+import "reflect-metadata";
+
+import { suite, test, timeout, retries } from "mocha-typescript";
+import * as chai from "chai";
+const expect = chai.expect;
+
+import { GiteaRestApi } from "./api";
+import { NotFoundError } from "../errors";
+import { GiteaContextParser } from "./gitea-context-parser";
+import { User } from "@gitpod/gitpod-protocol";
+import { ContainerModule, Container } from "inversify";
+import { Config } from "../config";
+import { DevData } from "../dev/dev-data";
+import { AuthProviderParams } from "../auth/auth-provider";
+import { TokenProvider } from "../user/token-provider";
+import { GiteaTokenHelper } from "./gitea-token-helper";
+import { HostContextProvider } from "../auth/host-context-provider";
+import { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if";
+
+@suite(timeout(10000), retries(1), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_GITEA"))
+class TestGiteaContextParser {
+ protected parser: GiteaContextParser;
+ protected user: User;
+
+ public before() {
+ const container = new Container();
+ container.load(
+ new ContainerModule((bind, unbind, isBound, rebind) => {
+ bind(Config).toConstantValue({
+ // meant to appease DI, but Config is never actually used here
+ });
+ bind(GiteaContextParser).toSelf().inSingletonScope();
+ bind(GiteaRestApi).toSelf().inSingletonScope();
+ bind(AuthProviderParams).toConstantValue(TestGiteaContextParser.AUTH_HOST_CONFIG);
+ bind(GiteaTokenHelper).toSelf().inSingletonScope();
+ bind(TokenProvider).toConstantValue({
+ getTokenForHost: async (user: User, host: string) => {
+ return DevData.createGiteaTestToken();
+ },
+ });
+ bind(HostContextProvider).toConstantValue(DevData.createDummyHostContextProvider());
+ }),
+ );
+ this.parser = container.get(GiteaContextParser);
+ this.user = DevData.createTestUser();
+ }
+
+ static readonly AUTH_HOST_CONFIG: Partial = {
+ id: "Public-Gitea",
+ type: "Gitea",
+ verified: true,
+ description: "",
+ icon: "",
+ host: "gitea.com",
+ oauth: "not-used" as any,
+ };
+
+ static readonly BRANCH_TEST = {
+ name: "test",
+ commit: {
+ sha: "testsha",
+ url: "testurl",
+ },
+ protected: false,
+ protection_url: "",
+ };
+
+ static readonly BRANCH_ISSUE_974 = {
+ name: "ak/lmcbout-issue_974",
+ commit: {
+ sha: "sha974",
+ url: "url974",
+ },
+ protected: false,
+ protection_url: "",
+ };
+
+ static readonly BLO_BLA_ERROR_DATA = {
+ host: "gitea.com",
+ lastUpdate: undefined,
+ owner: "blo",
+ repoName: "bla",
+ userIsOwner: false,
+ userScopes: [],
+ };
+
+ protected get bloBlaErrorData() {
+ return TestGiteaContextParser.BLO_BLA_ERROR_DATA;
+ }
+
+ @test public async testErrorContext_01() {
+ try {
+ await this.parser.handle({}, this.user, "https://gitea.com/blo/bla");
+ } catch (e) {
+ expect(NotFoundError.is(e));
+ expect(e.data).to.deep.equal(this.bloBlaErrorData);
+ }
+ }
+
+ @test public async testErrorContext_02() {
+ try {
+ await this.parser.handle({}, this.user, "https://gitea.com/blo/bla/pull/42");
+ } catch (e) {
+ expect(NotFoundError.is(e));
+ expect(e.data).to.deep.equal(this.bloBlaErrorData);
+ }
+ }
+
+ @test public async testErrorContext_03() {
+ try {
+ await this.parser.handle({}, this.user, "https://gitea.com/blo/bla/issues/42");
+ } catch (e) {
+ expect(NotFoundError.is(e));
+ expect(e.data).to.deep.equal(this.bloBlaErrorData);
+ }
+ }
+
+ @test public async testErrorContext_04() {
+ try {
+ await this.parser.handle({}, this.user, "https://gitea.com/blo/bla/tree/my/branch/path/foo.ts");
+ } catch (e) {
+ expect(NotFoundError.is(e));
+ expect(e.data).to.deep.equal(this.bloBlaErrorData);
+ }
+ }
+
+ @test public async testTreeContext_01() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/eclipse-theia/theia");
+ expect(result).to.deep.include({
+ ref: "master",
+ refType: "branch",
+ path: "",
+ isFile: false,
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ title: "eclipse-theia/theia - master",
+ });
+ }
+
+ @test public async testTreeContext_02() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/eclipse-theia/theia/tree/master");
+ expect(result).to.deep.include({
+ ref: "master",
+ refType: "branch",
+ path: "",
+ isFile: false,
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ title: "eclipse-theia/theia - master",
+ });
+ }
+
+ @test public async testTreeContext_03() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/eclipse-theia/theia/tree/master/LICENSE",
+ );
+ expect(result).to.deep.include({
+ ref: "master",
+ refType: "branch",
+ path: "LICENSE",
+ isFile: true,
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ title: "eclipse-theia/theia - master",
+ });
+ }
+
+ @test public async testTreeContext_04() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/blob/nametest/src/src/server.ts",
+ );
+ expect(result).to.deep.include({
+ ref: "nametest/src",
+ refType: "branch",
+ path: "src/server.ts",
+ isFile: true,
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ title: "gitpod-io/gitpod-test-repo - nametest/src",
+ });
+ }
+
+ @test public async testTreeContext_05() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/tree/499efbbcb50e7e6e5e2883053f72a34cd5396be3/folder1/folder2",
+ );
+ expect(result).to.deep.include({
+ title: "gitpod-io/gitpod-test-repo - 499efbbc:folder1/folder2",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ revision: "499efbbcb50e7e6e5e2883053f72a34cd5396be3",
+ isFile: false,
+ path: "folder1/folder2",
+ });
+ }
+
+ @test public async testTreeContext_06() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/Snailclimb/JavaGuide/blob/940982ebffa5f376b6baddeaf9ed41c91217a6b6/数据结构与算法/常见安全算法(MD5、SHA1、Base64等等)总结.md",
+ );
+ expect(result).to.deep.include({
+ title: "Snailclimb/JavaGuide - 940982eb:数据结构与算法/常见安全算法(MD5、SHA1、Base64等等)总结.md",
+ repository: {
+ host: "gitea.com",
+ owner: "Snailclimb",
+ name: "JavaGuide",
+ cloneUrl: "https://gitea.com/Snailclimb/JavaGuide.git",
+ private: false,
+ },
+ revision: "940982ebffa5f376b6baddeaf9ed41c91217a6b6",
+ isFile: true,
+ path: "数据结构与算法/常见安全算法(MD5、SHA1、Base64等等)总结.md",
+ });
+ }
+
+ @test public async testTreeContext_07() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/eclipse-theia/theia#license");
+ expect(result).to.deep.include({
+ ref: "master",
+ refType: "branch",
+ path: "",
+ isFile: false,
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ title: "eclipse-theia/theia - master",
+ });
+ }
+
+ @test public async testTreeContext_tag_01() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/eclipse-theia/theia/tree/v0.1.0");
+ expect(result).to.deep.include({
+ title: "eclipse-theia/theia - v0.1.0",
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ revision: "f29626847a14ca50dd78483aebaf4b4fe26bcb73",
+ isFile: false,
+ ref: "v0.1.0",
+ refType: "tag",
+ });
+ }
+
+ @test public async testReleasesContext_tag_01() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod/releases/tag/v0.9.0",
+ );
+ expect(result).to.deep.include({
+ ref: "v0.9.0",
+ refType: "tag",
+ isFile: false,
+ path: "",
+ title: "gitpod-io/gitpod - v0.9.0",
+ revision: "25ece59c495d525614f28971d41d5708a31bf1e3",
+ repository: {
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod.git",
+ host: "gitea.com",
+ name: "gitpod",
+ owner: "gitpod-io",
+ private: false,
+ },
+ });
+ }
+
+ @test public async testCommitsContext_01() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/commits/4test",
+ );
+ expect(result).to.deep.include({
+ ref: "4test",
+ refType: "branch",
+ path: "",
+ isFile: false,
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ title: "gitpod-io/gitpod-test-repo - 4test",
+ });
+ }
+
+ @test public async testCommitContext_01() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/commit/409ac2de49a53d679989d438735f78204f441634",
+ );
+ expect(result).to.deep.include({
+ ref: "",
+ refType: "revision",
+ path: "",
+ revision: "409ac2de49a53d679989d438735f78204f441634",
+ isFile: false,
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ title: "gitpod-io/gitpod-test-repo - Test 3",
+ });
+ }
+
+ @test public async testCommitContext_02_notExistingCommit() {
+ try {
+ await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ );
+ // ensure that an error has been thrown
+ chai.assert.fail();
+ } catch (e) {
+ expect(e.message).contains("Couldn't find commit");
+ }
+ }
+
+ @test public async testCommitContext_02_invalidSha() {
+ try {
+ await this.parser.handle({}, this.user, "https://gitea.com/gitpod-io/gitpod-test-repo/commit/invalid");
+ // ensure that an error has been thrown
+ chai.assert.fail();
+ } catch (e) {
+ expect(e.message).contains("Invalid commit ID");
+ }
+ }
+
+ @test public async testPullRequestContext_01() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/TypeFox/theia/pull/1");
+ expect(result).to.deep.include({
+ title: "Merge master",
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ ref: "master",
+ refType: "branch",
+ nr: 1,
+ base: {
+ repository: {
+ host: "gitea.com",
+ owner: "TypeFox",
+ name: "theia",
+ cloneUrl: "https://gitea.com/TypeFox/theia.git",
+ private: false,
+ fork: {
+ parent: {
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ host: "gitea.com",
+ name: "theia",
+ owner: "eclipse-theia",
+ private: false,
+ },
+ },
+ },
+ ref: "master",
+ refType: "branch",
+ },
+ });
+ }
+
+ @test public async testPullRequestthroughIssueContext_04() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/TypeFox/theia/issues/1");
+ expect(result).to.deep.include({
+ title: "Merge master",
+ repository: {
+ host: "gitea.com",
+ owner: "eclipse-theia",
+ name: "theia",
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ private: false,
+ },
+ ref: "master",
+ refType: "branch",
+ nr: 1,
+ base: {
+ repository: {
+ host: "gitea.com",
+ owner: "TypeFox",
+ name: "theia",
+ cloneUrl: "https://gitea.com/TypeFox/theia.git",
+ private: false,
+ fork: {
+ parent: {
+ cloneUrl: "https://gitea.com/eclipse-theia/theia.git",
+ host: "gitea.com",
+ name: "theia",
+ owner: "eclipse-theia",
+ private: false,
+ },
+ },
+ },
+ ref: "master",
+ refType: "branch",
+ },
+ });
+ }
+
+ @test public async testIssueContext_01() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/issues/42",
+ );
+ expect(result).to.deep.include({
+ title: "Test issue web-extension",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ owner: "gitpod-io",
+ nr: 42,
+ ref: "1test",
+ refType: "branch",
+ localBranch: "somefox/test-issue-web-extension-42",
+ });
+ }
+
+ @test public async testIssuePageContext() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/gitpod-io/gitpod-test-repo/issues");
+ expect(result).to.deep.include({
+ title: "gitpod-io/gitpod-test-repo - 1test",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ ref: "1test",
+ refType: "branch",
+ });
+ }
+
+ @test public async testIssueThroughPullRequestContext() {
+ const result = await this.parser.handle({}, this.user, "https://gitea.com/gitpod-io/gitpod-test-repo/pull/42");
+ expect(result).to.deep.include({
+ title: "Test issue web-extension",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ owner: "gitpod-io",
+ nr: 42,
+ ref: "1test",
+ refType: "branch",
+ localBranch: "somefox/test-issue-web-extension-42",
+ });
+ }
+
+ @test public async testBlobContext_01() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/blob/aba298d5084a817cdde3dd1f26692bc2a216e2b9/test-comment-01.md",
+ );
+ expect(result).to.deep.include({
+ title: "gitpod-io/gitpod-test-repo - aba298d5:test-comment-01.md",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ revision: "aba298d5084a817cdde3dd1f26692bc2a216e2b9",
+ isFile: true,
+ path: "test-comment-01.md",
+ });
+ }
+
+ @test public async testBlobContext_02() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/blob/499efbbcb50e7e6e5e2883053f72a34cd5396be3/folder1/folder2/content2",
+ );
+ expect(result).to.deep.include({
+ title: "gitpod-io/gitpod-test-repo - 499efbbc:folder1/folder2/content2",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ revision: "499efbbcb50e7e6e5e2883053f72a34cd5396be3",
+ isFile: true,
+ path: "folder1/folder2/content2",
+ });
+ }
+
+ @test public async testBlobContextShort_01() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/blob/499efbbc/folder1/folder2/content2",
+ );
+ expect(result).to.deep.include({
+ title: "gitpod-io/gitpod-test-repo - 499efbbc:folder1/folder2/content2",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ revision: "499efbbcb50e7e6e5e2883053f72a34cd5396be3",
+ isFile: true,
+ path: "folder1/folder2/content2",
+ });
+ }
+
+ @test public async testBlobContextShort_02() {
+ const result = await this.parser.handle(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo/blob/499ef/folder1/folder2/content2",
+ );
+ expect(result).to.deep.include({
+ title: "gitpod-io/gitpod-test-repo - 499efbbc:folder1/folder2/content2",
+ repository: {
+ host: "gitea.com",
+ owner: "gitpod-io",
+ name: "gitpod-test-repo",
+ cloneUrl: "https://gitea.com/gitpod-io/gitpod-test-repo.git",
+ private: false,
+ },
+ revision: "499efbbcb50e7e6e5e2883053f72a34cd5396be3",
+ isFile: true,
+ path: "folder1/folder2/content2",
+ });
+ }
+
+ @test public async testFetchCommitHistory() {
+ const result = await this.parser.fetchCommitHistory(
+ {},
+ this.user,
+ "https://gitea.com/gitpod-io/gitpod-test-repo",
+ "409ac2de49a53d679989d438735f78204f441634",
+ 100,
+ );
+ expect(result).to.deep.equal([
+ "506e5aed317f28023994ecf8ca6ed91430e9c1a4",
+ "f5b041513bfab914b5fbf7ae55788d9835004d76",
+ ]);
+ }
+}
+module.exports = new TestGiteaContextParser(); // Only to circumvent no usage warning :-/
diff --git a/components/server/src/gitea/gitea-context-parser.ts b/components/server/src/gitea/gitea-context-parser.ts
new file mode 100644
index 00000000000000..3e01f787931d27
--- /dev/null
+++ b/components/server/src/gitea/gitea-context-parser.ts
@@ -0,0 +1,520 @@
+/**
+ * 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 { injectable, inject } from "inversify";
+
+import {
+ Repository,
+ PullRequestContext,
+ NavigatorContext,
+ IssueContext,
+ User,
+ CommitContext,
+ RefType,
+} from "@gitpod/gitpod-protocol";
+import { Gitea, GiteaRestApi } from "./api";
+import { NotFoundError, UnauthorizedError } from "../errors";
+import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
+import { IContextParser, IssueContexts, AbstractContextParser } from "../workspace/context-parser";
+import { GiteaScope } from "./scopes";
+import { GiteaTokenHelper } from "./gitea-token-helper";
+import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
+import { convertRepo } from "./convert";
+
+const path = require("path"); // TODO(anbraten): is this really needed / correct?
+
+@injectable()
+export class GiteaContextParser extends AbstractContextParser implements IContextParser {
+ @inject(GiteaRestApi) protected readonly giteaApi: GiteaRestApi;
+ @inject(GiteaTokenHelper) protected readonly tokenHelper: GiteaTokenHelper;
+
+ protected get authProviderId() {
+ return this.config.id;
+ }
+
+ public async handle(ctx: TraceContext, user: User, contextUrl: string): Promise {
+ const span = TraceContext.startSpan("GiteaContextParser.handle", ctx);
+
+ try {
+ const { host, owner, repoName, moreSegments } = await this.parseURL(user, contextUrl);
+ if (moreSegments.length > 0) {
+ switch (moreSegments[0]) {
+ case "pulls": {
+ // https://host/owner/repo/pulls/123
+ const prNr = parseInt(moreSegments[1], 10);
+ if (isNaN(prNr)) break;
+ return await this.handlePullRequestContext({ span }, user, host, owner, repoName, prNr);
+ }
+ case "src": // https://host/owner/repo/src/branch/main/path/to/folder/or/file.yml
+ case "commits": {
+ // https://host/owner/repo/commits/branch/main
+ return await this.handleTreeContext(
+ { span },
+ user,
+ host,
+ owner,
+ repoName,
+ moreSegments.slice(1),
+ );
+ }
+ case "releases": {
+ // https://host/owner/repo/releases/tag/1.0.0
+ if (moreSegments.length > 1 && moreSegments[1] === "tag") {
+ return await this.handleTreeContext(
+ { span },
+ user,
+ host,
+ owner,
+ repoName,
+ moreSegments.slice(2),
+ );
+ }
+ break;
+ }
+ case "issues": {
+ // https://host/owner/repo/issues/123
+ const issueNr = parseInt(moreSegments[1], 10);
+ if (isNaN(issueNr)) break;
+ return await this.handleIssueContext({ span }, user, host, owner, repoName, issueNr);
+ }
+ case "commit": {
+ // https://host/owner/repo/commit/cfbaea9ee7d24d95e30e0bf2d4f75e83481815bc
+ return await this.handleCommitContext({ span }, user, host, owner, repoName, moreSegments[1]);
+ }
+ }
+ }
+ return await this.handleDefaultContext({ span }, user, host, owner, repoName);
+ } catch (error) {
+ // console.log("ass", error);
+ if (error && error.code === 401) {
+ const token = await this.tokenHelper.getCurrentToken(user);
+ if (token) {
+ const scopes = token.scopes;
+ // most likely the token needs to be updated after revoking by user.
+ throw UnauthorizedError.create(this.config.host, scopes, "http-unauthorized");
+ }
+ // todo@alex: this is very unlikely. is coercing it into a valid case helpful?
+ // here, GH API responded with a 401 code, and we are missing a token. OTOH, a missing token would not lead to a request.
+ throw UnauthorizedError.create(
+ this.config.host,
+ GiteaScope.Requirements.PUBLIC_REPO,
+ "missing-identity",
+ );
+ }
+ throw error;
+ } finally {
+ span.finish();
+ }
+ }
+
+ protected async handleDefaultContext(
+ ctx: TraceContext,
+ user: User,
+ host: string,
+ owner: string,
+ repoName: string,
+ ): Promise {
+ try {
+ const repository = await this.fetchRepo(user, owner, repoName);
+ if (!repository.defaultBranch) {
+ return {
+ isFile: false,
+ path: "",
+ title: `${owner}/${repoName}`,
+ repository,
+ };
+ }
+
+ try {
+ const branchOrTag = await this.getBranchOrTag(user, owner, repoName, [repository.defaultBranch!]);
+ return {
+ isFile: false,
+ path: "",
+ title: `${owner}/${repoName} - ${branchOrTag.name}`,
+ ref: branchOrTag.name,
+ revision: branchOrTag.revision,
+ refType: branchOrTag.type,
+ repository,
+ };
+ } catch (error) {
+ if (
+ error &&
+ error.message &&
+ (error.message as string).startsWith("Cannot find tag/branch for context")
+ ) {
+ // the repo is empty (has no branches)
+ return {
+ isFile: false,
+ path: "",
+ title: `${owner}/${repoName} - ${repository.defaultBranch}`,
+ revision: "",
+ repository,
+ };
+ } else {
+ throw error;
+ }
+ }
+ } catch (error) {
+ if (UnauthorizedError.is(error)) {
+ throw error;
+ }
+ // log.error({ userId: user.id }, error);
+ throw await NotFoundError.create(await this.tokenHelper.getCurrentToken(user), user, host, owner, repoName);
+ }
+ }
+
+ protected async handleTreeContext(
+ ctx: TraceContext,
+ user: User,
+ host: string,
+ owner: string,
+ repoName: string,
+ segments: string[],
+ ): Promise {
+ try {
+ const branchOrTagPromise =
+ segments.length > 0 ? this.getBranchOrTag(user, owner, repoName, segments) : undefined;
+ const repository = await this.fetchRepo(user, owner, repoName);
+ const branchOrTag = await branchOrTagPromise;
+ const context = {
+ isFile: false,
+ path: "",
+ title: `${owner}/${repoName}` + (branchOrTag ? ` - ${branchOrTag.name}` : ""),
+ ref: branchOrTag && branchOrTag.name,
+ revision: branchOrTag && branchOrTag.revision,
+ refType: branchOrTag && branchOrTag.type,
+ repository,
+ };
+ if (!branchOrTag) {
+ return context;
+ }
+ if (segments.length === 1 || branchOrTag.fullPath.length === 0) {
+ return context;
+ }
+
+ const result = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGetContents(owner, repoName, path.dirname(branchOrTag.fullPath), {
+ ref: branchOrTag.name,
+ });
+ });
+ if (Gitea.ApiError.is(result)) {
+ throw new Error(`Error reading TREE ${owner}/${repoName}/tree/${segments.join("/")}: ${result}`);
+ } else {
+ const object = result.find((o) => o.path === branchOrTag.fullPath);
+ if (object) {
+ const isFile = object.type === "blob";
+ context.isFile = isFile;
+ context.path = branchOrTag.fullPath;
+ }
+ }
+ return context;
+ } catch (e) {
+ log.debug("Gitea context parser: Error handle tree context.", e);
+ throw e;
+ }
+ }
+
+ protected async getBranchOrTag(
+ user: User,
+ owner: string,
+ repoName: string,
+ segments: string[],
+ ): Promise<{ type: RefType; name: string; revision: string; fullPath: string }> {
+ let branchOrTagObject: { type: RefType; name: string; revision: string } | undefined = undefined;
+
+ // `segments` could have branch/tag name parts as well as file path parts.
+ // We never know which segments belong to the branch/tag name and which are already folder names.
+ // Here we generate a list of candidates for branch/tag names.
+ const branchOrTagCandidates: string[] = [];
+ // Try the concatination of all segments first.
+ branchOrTagCandidates.push(segments.join("/"));
+ // Then all subsets.
+ for (let i = 1; i < segments.length; i++) {
+ branchOrTagCandidates.push(segments.slice(0, i).join("/"));
+ }
+
+ for (const candidate of branchOrTagCandidates) {
+ // Check if there is a BRANCH with name `candidate`:
+ const possibleBranch = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGetBranch(owner, repoName, candidate);
+ });
+ // If the branch does not exist, the Gitea API returns with NotFound or InternalServerError.
+ const isNotFoundBranch =
+ Gitea.ApiError.is(possibleBranch) &&
+ (Gitea.ApiError.isNotFound(possibleBranch) || Gitea.ApiError.isInternalServerError(possibleBranch));
+ if (!isNotFoundBranch) {
+ if (Gitea.ApiError.is(possibleBranch)) {
+ throw new Error(
+ `Gitea ApiError on searching for possible branches for ${owner}/${repoName}/tree/${segments.join(
+ "/",
+ )}: ${possibleBranch}`,
+ );
+ }
+
+ if (!possibleBranch.commit?.id || !possibleBranch.name) {
+ throw new Error(
+ `Gitea ApiError on searching for possible branches for ${owner}/${repoName}/tree/${segments.join(
+ "/",
+ )}: ${possibleBranch}`,
+ );
+ }
+
+ branchOrTagObject = { type: "branch", name: possibleBranch.name, revision: possibleBranch.commit.id };
+ break;
+ }
+
+ // Check if there is a TAG with name `candidate`:
+ const possibleTag = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGetTag(owner, repoName, candidate);
+ });
+ // TODO
+ // If the tag does not exist, the GitLab API returns with NotFound or InternalServerError.
+ const isNotFoundTag =
+ Gitea.ApiError.is(possibleTag) &&
+ (Gitea.ApiError.isNotFound(possibleTag) || Gitea.ApiError.isInternalServerError(possibleTag));
+ if (!isNotFoundTag) {
+ if (Gitea.ApiError.is(possibleTag)) {
+ throw new Error(
+ `Gitea ApiError on searching for possible tags for ${owner}/${repoName}/tree/${segments.join(
+ "/",
+ )}: ${possibleTag}`,
+ );
+ }
+
+ if (!possibleTag.commit?.sha || !possibleTag.name) {
+ throw new Error(
+ `Gitea ApiError on searching for possible branches for ${owner}/${repoName}/tree/${segments.join(
+ "/",
+ )}: ${possibleBranch}`,
+ );
+ }
+
+ branchOrTagObject = { type: "tag", name: possibleTag.name, revision: possibleTag.commit.sha };
+ break;
+ }
+ }
+
+ // There seems to be no matching branch or tag.
+ if (branchOrTagObject === undefined) {
+ log.debug(`Cannot find tag/branch for context: ${owner}/${repoName}/tree/${segments.join("/")}.`, {
+ branchOrTagCandidates,
+ });
+ throw new Error(`Cannot find tag/branch for context: ${owner}/${repoName}/tree/${segments.join("/")}.`);
+ }
+
+ const remainingSegmentsIndex = branchOrTagObject.name.split("/").length;
+ const fullPath = decodeURIComponent(
+ segments
+ .slice(remainingSegmentsIndex)
+ .filter((s) => s.length > 0)
+ .join("/"),
+ );
+
+ return { ...branchOrTagObject, fullPath };
+ }
+
+ protected async handlePullRequestContext(
+ ctx: TraceContext,
+ user: User,
+ host: string,
+ owner: string,
+ repoName: string,
+ nr: number,
+ ): Promise {
+ const result = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGetPullRequest(owner, repoName, nr);
+ });
+ if (Gitea.ApiError.is(result)) {
+ throw await NotFoundError.create(await this.tokenHelper.getCurrentToken(user), user, host, owner, repoName);
+ }
+
+ if (!result.base?.repo || !result.head?.repo || !result.title) {
+ throw new Error(
+ `Missing relevant commit information for pull-request ${nr} from repository ${owner}/${repoName}`,
+ );
+ }
+
+ const sourceRepo = convertRepo(result.head?.repo);
+ const targetRepo = convertRepo(result.base?.repo);
+
+ return {
+ title: result.title,
+ repository: sourceRepo,
+ ref: result.head.ref,
+ refType: "branch",
+ revision: result.head.sha,
+ nr,
+ base: {
+ repository: targetRepo,
+ ref: result.base.ref,
+ refType: "branch",
+ },
+ };
+ }
+
+ protected async fetchRepo(user: User, owner: string, repoName: string): Promise {
+ // host might be a relative URL
+ const host = this.host; // as per contract, cf. `canHandle(user, contextURL)`
+
+ const result = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGet(owner, repoName);
+ });
+ if (Gitea.ApiError.is(result)) {
+ throw result;
+ }
+ const repo = {
+ host,
+ name: repoName,
+ owner: owner,
+ cloneUrl: result.clone_url,
+ defaultBranch: result.default_branch,
+ private: result.private,
+ };
+ // TODO: support forks
+ // if (result.fork) {
+ // // host might be a relative URL, let's compute the prefix
+ // const url = new URL(forked_from_project.http_url_to_repo.split(forked_from_project.namespace.full_path)[0]);
+ // const relativePath = url.pathname.slice(1); // hint: pathname always starts with `/`
+ // const host = relativePath ? `${url.hostname}/${relativePath}` : url.hostname;
+
+ // repo.fork = {
+ // parent: {
+ // name: forked_from_project.path,
+ // host,
+ // owner: forked_from_project.namespace.full_path,
+ // cloneUrl: forked_from_project.http_url_to_repo,
+ // defaultBranch: forked_from_project.default_branch
+ // }
+ // }
+ // }
+ return repo;
+ }
+
+ protected async fetchCommit(user: User, owner: string, repoName: string, sha: string) {
+ const result = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGetSingleCommit(owner, repoName, sha);
+ });
+ if (Gitea.ApiError.is(result)) {
+ if (result.message === "Gitea responded with code 404") {
+ throw new Error(`Couldn't find commit #${sha} in repository ${owner}/${repoName}.`);
+ }
+ throw result;
+ }
+
+ if (!result.sha || !result.commit?.message) {
+ throw new Error(`The commit does not have all needed data ${owner}/${repoName}.`);
+ }
+
+ return {
+ id: result.sha, // TODO: how can we use a proper commit-id instead of the sha
+ title: result.commit?.message,
+ };
+ }
+
+ protected async handleIssueContext(
+ ctx: TraceContext,
+ user: User,
+ host: string,
+ owner: string,
+ repoName: string,
+ nr: number,
+ ): Promise {
+ const result = await this.giteaApi.run(user, async (g) => {
+ return g.repos.issueGetIssue(owner, repoName, nr);
+ });
+ if (Gitea.ApiError.is(result) || !result.title || !result.id) {
+ throw await NotFoundError.create(await this.tokenHelper.getCurrentToken(user), user, host, owner, repoName);
+ }
+
+ const context = await this.handleDefaultContext(ctx, user, host, owner, repoName);
+ return {
+ ...context,
+ title: result.title,
+ owner,
+ nr,
+ localBranch: IssueContexts.toBranchName(user, result.title, result.id),
+ };
+ }
+
+ protected async handleCommitContext(
+ ctx: TraceContext,
+ user: User,
+ host: string,
+ owner: string,
+ repoName: string,
+ sha: string,
+ ): Promise {
+ const repository = await this.fetchRepo(user, owner, repoName);
+ if (Gitea.ApiError.is(repository)) {
+ throw await NotFoundError.create(await this.tokenHelper.getCurrentToken(user), user, host, owner, repoName);
+ }
+ const commit = await this.fetchCommit(user, owner, repoName, sha);
+ if (Gitea.ApiError.is(commit)) {
+ throw new Error(`Couldn't find commit #${sha} in repository ${owner}/${repoName}.`);
+ }
+ return {
+ path: "",
+ ref: "",
+ refType: "revision",
+ isFile: false,
+ title: `${owner}/${repoName} - ${commit.title}`,
+ owner,
+ revision: sha,
+ repository,
+ };
+ }
+
+ public async fetchCommitHistory(
+ ctx: TraceContext,
+ user: User,
+ contextUrl: string,
+ sha: string,
+ maxDepth: number,
+ ): Promise {
+ const span = TraceContext.startSpan("GiteaContextParser.fetchCommitHistory", ctx);
+
+ try {
+ if (sha.length != 40) {
+ throw new Error(`Invalid commit ID ${sha}.`);
+ }
+
+ // TODO(janx): To get more results than Gitea API's max page size (seems to be 100), pagination should be handled.
+ // These additional history properties may be helfpul:
+ // totalCount,
+ // pageInfo {
+ // haxNextPage,
+ // },
+ const { owner, repoName } = await this.parseURL(user, contextUrl);
+ const result = await this.giteaApi.run(user, async (g) => {
+ return g.repos.repoGetAllCommits(owner, repoName, {
+ sha,
+ limit: maxDepth,
+ page: 1,
+ });
+ });
+ if (Gitea.ApiError.is(result)) {
+ if (result.message === "Gitea responded with code 404") {
+ throw new Error(`Couldn't find commit #${sha} in repository ${owner}/${repoName}.`);
+ }
+ throw result;
+ }
+
+ return result.slice(1).map((c) => {
+ // TODO: how can we use a proper commit-id instead of the sha
+ if (!c.sha) {
+ throw new Error(`Commit #${sha} does not have commit.`);
+ }
+
+ return c.sha;
+ });
+ } catch (e) {
+ span.log({ error: e });
+ throw e;
+ } finally {
+ span.finish();
+ }
+ }
+}
diff --git a/components/server/src/gitea/gitea-repository-provider.ts b/components/server/src/gitea/gitea-repository-provider.ts
new file mode 100644
index 00000000000000..764a2c7d474abe
--- /dev/null
+++ b/components/server/src/gitea/gitea-repository-provider.ts
@@ -0,0 +1,147 @@
+/**
+ * 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 { injectable, inject } from 'inversify';
+
+import { User, Repository } from "@gitpod/gitpod-protocol"
+import { Gitea, GiteaRestApi } from "./api";
+import { RepositoryProvider } from '../repohost/repository-provider';
+import { RepoURL } from '../repohost/repo-url';
+import { Branch, CommitInfo } from '@gitpod/gitpod-protocol/src/protocol';
+
+@injectable()
+export class GiteaRepositoryProvider implements RepositoryProvider {
+ @inject(GiteaRestApi) protected readonly giteaApi: GiteaRestApi;
+
+ async getRepo(user: User, owner: string, repoName: string): Promise {
+ const result = await this.giteaApi.run(user, (g => g.repos.repoGet(owner, repoName)));
+ if (Gitea.ApiError.is(result)) {
+ throw new Error(`Can't get repository ${owner}/${repoName}`);
+ }
+
+ if (!result.clone_url) {
+ throw new Error(`Can't find clone_url for repository ${owner}/${repoName}`);
+ }
+
+ const host = RepoURL.parseRepoUrl(result.clone_url)!.host;
+ return { host, owner, name: repoName, cloneUrl: result.clone_url, description: result.description, avatarUrl: result.avatar_url, webUrl: result.html_url, defaultBranch: result.default_branch };
+ }
+
+ async getBranch(user: User, owner: string, repoName: string, branch: string): Promise {
+ // TODO: we currently use ref as sha :thinking:
+ const result = await this.giteaApi.run(user, (g => g.repos.repoGetBranch(owner, repoName, branch)));
+ if (Gitea.ApiError.is(result)) {
+ throw new Error(`Can't get branch ${branch} from repository ${owner}/${repoName}`);
+ }
+
+ if (!result.name || !result.commit?.author?.name || !result.commit?.message || !result.commit?.added) {
+ throw new Error(`Missing relevant commit information for branch ${branch} from repository ${owner}/${repoName}`);
+ }
+
+ return {
+ name: result.name,
+ htmlUrl: '', // TODO: find way to get branch url / create it manually
+ commit: {
+ author: result.commit.author.name,
+ sha: '', // TODO: find way to get branch sha
+ commitMessage: result.commit.message,
+ authorAvatarUrl: '', // TODO: find way to get author avatar
+ authorDate: '', // TODO: find way to get author date
+ },
+ };
+ }
+
+ async getBranches(user: User, owner: string, repoName: string): Promise {
+ // TODO: we currently use ref as sha :thinking:
+ const result = await this.giteaApi.run(user, (g => g.repos.repoListBranches(owner, repoName)));
+ if (Gitea.ApiError.is(result)) {
+ throw new Error(`Can't get branches from repository ${owner}/${repoName}`);
+ }
+
+ return result.map((branch) => {
+ if (!branch.name || !branch.commit?.author?.name || !branch.commit?.message || branch.commit?.added) {
+ throw new Error(`Missing relevant commit information for branch ${branch.name} from repository ${owner}/${repoName}`);
+ }
+
+ return {
+ name: branch.name,
+ htmlUrl: '', // TODO: find way to get branch url / create it manually
+ commit: {
+ author: branch.commit.author.name,
+ sha: '', // TODO: find way to get branch sha
+ commitMessage: branch.commit.message,
+ authorAvatarUrl: '', // TODO: find way to get author avatar
+ authorDate: '', // TODO: find way to get author date
+ },
+ };
+ });
+ }
+
+ async getCommitInfo(user: User, owner: string, repoName: string, ref: string): Promise {
+ // TODO: we currently use ref as sha :thinking:
+ const result = await this.giteaApi.run(user, (g => g.repos.repoGetSingleCommit(owner, repoName, ref)));
+ if (Gitea.ApiError.is(result)) {
+ throw new Error(`Can't get commit for ref ${ref} from repository ${owner}/${repoName}`);
+ }
+
+ if (!result.author?.login || !result.commit?.message || !result.sha) {
+ throw new Error(`Missing relevant commit information for ref ${ref} from repository ${owner}/${repoName}`);
+ }
+
+ return {
+ author: result.author?.login, // TODO: is this correct?
+ commitMessage: result.commit?.message,
+ sha: result.sha,
+ authorAvatarUrl: result.author?.avatar_url,
+ authorDate: result.created, // TODO: is this correct?
+ };
+ }
+
+ public async getCommitHistory(user: User, owner: string, repo: string, ref: string, maxDepth: number = 100): Promise {
+ // TODO(janx): To get more results than GitLab API's max per_page (seems to be 100), pagination should be handled.
+ const result = await this.giteaApi.run(user, async g => {
+ return g.repos.repoGetAllCommits(owner, repo, {
+ // TODO(anbraten): pass ref to call
+ // ref_name: ref,
+ // per_page: maxDepth,
+ page: 1,
+ });
+ });
+ if (Gitea.ApiError.is(result)) {
+ if (result.message === 'GitLab responded with code 404') {
+ throw new Error(`Couldn't find commit #${ref} in repository ${owner}/${repo}.`);
+ }
+ throw result;
+ }
+
+ // TODO(anbraten): use some unique id instead of sha
+ return result.slice(1).map((c) => c.sha || '');
+ }
+
+ async getUserRepos(user: User): Promise {
+ const result = await this.giteaApi.run(user, (g => g.user.userCurrentListRepos()));
+ if (Gitea.ApiError.is(result)) {
+ throw new Error(`Can't get repositories for user ${user.name}`);
+ }
+
+ // TODO: do we need the html url or clone urls?
+ return (result || []).map((repo) => repo.html_url || '').filter(s => s !== "")
+ }
+
+ async hasReadAccess(user: User, owner: string, repo: string): Promise {
+ try {
+ // If we can "see" a project we are allowed to read it
+ const api = await this.giteaApi;
+ const response = await api.run(user, (g => g.repos.repoGet(owner, repo)));
+ if (Gitea.ApiError.is(response)) {
+ return false;
+ }
+ return true;
+ } catch (err) {
+ return false;
+ }
+ }
+}
diff --git a/components/server/src/gitea/gitea-token-helper.ts b/components/server/src/gitea/gitea-token-helper.ts
new file mode 100644
index 00000000000000..cfe4171d2473da
--- /dev/null
+++ b/components/server/src/gitea/gitea-token-helper.ts
@@ -0,0 +1,48 @@
+/**
+ * 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 { injectable, inject } from "inversify";
+import { AuthProviderParams } from "../auth/auth-provider";
+import { User, Token } from "@gitpod/gitpod-protocol";
+import { UnauthorizedError } from "../errors";
+import { GiteaScope } from "./scopes";
+import { TokenProvider } from "../user/token-provider";
+
+@injectable()
+export class GiteaTokenHelper {
+ @inject(AuthProviderParams) readonly config: AuthProviderParams;
+ @inject(TokenProvider) protected readonly tokenProvider: TokenProvider;
+
+ async getCurrentToken(user: User) {
+ try {
+ return await this.getTokenWithScopes(user, [/* any scopes */]);
+ } catch {
+ // no token
+ }
+ }
+
+ async getTokenWithScopes(user: User, requiredScopes: string[]) {
+ const { host } = this.config;
+ try {
+ const token = await this.tokenProvider.getTokenForHost(user, host);
+ if (this.containsScopes(token, requiredScopes)) {
+ return token;
+ }
+ } catch {
+ // no token
+ }
+ if (requiredScopes.length === 0) {
+ requiredScopes = GiteaScope.Requirements.DEFAULT
+ }
+ throw UnauthorizedError.create(host, requiredScopes, "missing-identity");
+ }
+ protected containsScopes(token: Token, wantedScopes: string[] | undefined): boolean {
+ const wantedSet = new Set(wantedScopes);
+ const currentScopes = [...token.scopes];
+ currentScopes.forEach(s => wantedSet.delete(s));
+ return wantedSet.size === 0;
+ }
+}
diff --git a/components/server/src/gitea/gitea-token-validator.ts b/components/server/src/gitea/gitea-token-validator.ts
new file mode 100644
index 00000000000000..5bae06fd84dffe
--- /dev/null
+++ b/components/server/src/gitea/gitea-token-validator.ts
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2021 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 { inject, injectable } from "inversify";
+import { CheckWriteAccessResult, IGitTokenValidator, IGitTokenValidatorParams } from "../workspace/git-token-validator";
+import { Gitea, GiteaRestApi } from "./api";
+import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
+
+@injectable()
+export class GiteaTokenValidator implements IGitTokenValidator {
+ @inject(GiteaRestApi) giteaApi: GiteaRestApi;
+
+ async checkWriteAccess(params: IGitTokenValidatorParams): Promise {
+ const { token, owner, repo: repoName } = params;
+
+ const repo = await this.giteaApi.run(token, (api) => api.repos.repoGet(owner, repoName));
+ if (Gitea.ApiError.is(repo) && Gitea.ApiError.isNotFound(repo)) {
+ return { found: false };
+ } else if (Gitea.ApiError.is(repo)) {
+ log.error("Error getting repo information from Gitea", repo, { repo, owner });
+ return { found: false, error: repo };
+ }
+
+ const isPrivateRepo = repo.private;
+ let writeAccessToRepo = repo.permissions?.push;
+
+ return {
+ found: true,
+ isPrivateRepo,
+ writeAccessToRepo,
+ mayWritePrivate: true,
+ mayWritePublic: true,
+ };
+ }
+}
diff --git a/components/server/src/gitea/gitea-urls.ts b/components/server/src/gitea/gitea-urls.ts
new file mode 100644
index 00000000000000..eaf177e15f7ea6
--- /dev/null
+++ b/components/server/src/gitea/gitea-urls.ts
@@ -0,0 +1,14 @@
+/**
+ * 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.
+ */
+
+
+export function oauthUrls(host: string) {
+ return {
+ authorizationUrl: `https://${host}/login/oauth/authorize`,
+ tokenUrl: `https://${host}/login/oauth/access_token`,
+ settingsUrl: `https://${host}/user/settings/applications`,
+ }
+}
diff --git a/components/server/src/gitea/languages-provider.ts b/components/server/src/gitea/languages-provider.ts
new file mode 100644
index 00000000000000..66e22826343c41
--- /dev/null
+++ b/components/server/src/gitea/languages-provider.ts
@@ -0,0 +1,27 @@
+/**
+ * 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 { injectable, inject } from 'inversify';
+
+import { User, Repository } from "@gitpod/gitpod-protocol"
+import { Gitea, GiteaRestApi } from "./api";
+import { LanguagesProvider } from '../repohost/languages-provider';
+
+@injectable()
+export class GiteaLanguagesProvider implements LanguagesProvider {
+
+ @inject(GiteaRestApi) protected readonly gitea: GiteaRestApi;
+
+ async getLanguages(repository: Repository, user: User): Promise