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 { + const languages = await this.gitea.run(user, (gitea) => gitea.repos.repoGetLanguages(repository.owner, repository.name )); + + if (Gitea.ApiError.is(languages)) { + throw new Error(`Can\' get languages from repository ${repository.owner}/${repository.name}`); + } + + return languages; + } +} diff --git a/components/server/src/gitea/scopes.ts b/components/server/src/gitea/scopes.ts new file mode 100644 index 00000000000000..439594118d3366 --- /dev/null +++ b/components/server/src/gitea/scopes.ts @@ -0,0 +1,21 @@ +/** + * 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 namespace GiteaScope { + // TODO: currently Gitea does not support scopes (https://github.com/go-gitea/gitea/issues/4300) + export const All = []; + export const Requirements = { + /** + * Minimal required permission. + * Gitea's API is not restricted any further. + */ + DEFAULT: [], + + PUBLIC_REPO: [], + PRIVATE_REPO: [], + } +} diff --git a/components/server/src/prebuilds/gitea-app.ts b/components/server/src/prebuilds/gitea-app.ts new file mode 100644 index 00000000000000..e9d1a396bb0b92 --- /dev/null +++ b/components/server/src/prebuilds/gitea-app.ts @@ -0,0 +1,86 @@ +/** + * 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 * as express from 'express'; +import { postConstruct, injectable, inject } from 'inversify'; +import { ProjectDB, TeamDB, UserDB } from '@gitpod/gitpod-db/lib'; +import { Project, User, StartPrebuildResult } from '@gitpod/gitpod-protocol'; +import { PrebuildManager } from '../prebuilds/prebuild-manager'; +import { TraceContext } from '@gitpod/gitpod-protocol/lib/util/tracing'; +import { TokenService } from '../../../src/user/token-service'; +import { HostContextProvider } from '../../../src/auth/host-context-provider'; +// import { GiteaService } from './gitea-service'; + +@injectable() +export class GiteaApp { + + @inject(UserDB) protected readonly userDB: UserDB; + @inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager; + @inject(TokenService) protected readonly tokenService: TokenService; + @inject(HostContextProvider) protected readonly hostCtxProvider: HostContextProvider; + @inject(ProjectDB) protected readonly projectDB: ProjectDB; + @inject(TeamDB) protected readonly teamDB: TeamDB; + + protected _router = express.Router(); + public static path = '/apps/gitea/'; + + @postConstruct() + protected init() { + // TODO + } + + protected async findUser(ctx: TraceContext, context: GiteaPushHook, req: express.Request): Promise { + // TODO + return {} as User; + } + + protected async handlePushHook(ctx: TraceContext, body: GiteaPushHook, user: User): Promise { + // TODO + return undefined; + } + + /** + * Finds the relevant user account and project to the provided webhook event information. + * + * First of all it tries to find the project for the given `cloneURL`, then it tries to + * find the installer, which is also supposed to be a team member. As a fallback, it + * looks for a team member which also has a gitlab.com connection. + * + * @param cloneURL of the webhook event + * @param webhookInstaller the user account known from the webhook installation + * @returns a promise which resolves to a user account and an optional project. + */ + protected async findProjectAndOwner(cloneURL: string, webhookInstaller: User): Promise<{ user: User, project?: Project }> { + // TODO + return {} as { user: User, project?: Project }; + } + + protected createContextUrl(body: GiteaPushHook) { + // TODO + return {}; + } + + get router(): express.Router { + return this._router; + } + + protected getBranchFromRef(ref: string): string | undefined { + const headsPrefix = "refs/heads/"; + if (ref.startsWith(headsPrefix)) { + return ref.substring(headsPrefix.length); + } + + return undefined; + } +} + +interface GiteaPushHook { +} + +// interface GiteaRepository { +// } + +// interface GiteaProject {} \ No newline at end of file diff --git a/components/server/src/prebuilds/gitea-service.ts b/components/server/src/prebuilds/gitea-service.ts new file mode 100644 index 00000000000000..6e497fe5f788b4 --- /dev/null +++ b/components/server/src/prebuilds/gitea-service.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2020 Gitpod GmbH. All rights reserved. + * Licensed under the Gitpod Enterprise Source Code License, + * See License.enterprise.txt in the project root folder. + */ + +import { RepositoryService } from "../../../src/repohost/repo-service"; +import { inject, injectable } from "inversify"; +import { GiteaRestApi } from "../../../src/gitea/api"; + +@injectable() +export class GiteaService extends RepositoryService { + @inject(GiteaRestApi) protected readonly giteaApi: GiteaRestApi; + // TODO: complete? +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 5947aa8f47248b..6a7af0e030ca17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6120,7 +6120,18 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +<<<<<<< HEAD +cross-fetch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + +cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +======= cross-spawn@^7.0.2, cross-spawn@^7.0.3: +>>>>>>> upstream/main version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -8212,7 +8223,43 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +<<<<<<< HEAD +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +getos@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" + integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== + dependencies: + async "^3.2.0" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +gitea-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gitea-js/-/gitea-js-1.2.0.tgz#b118b003c1ca4499d9ad0a4f15f343967a362cb4" + integrity sha512-/1Xqs8wVWnSTtIJ3JffXbPwVtmJjA+qjVnmdZ0DShj1RKgKzYlqfXT8JUJ7lBdLHXauZrp5VFqldIKrbmWCqKg== + +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-parent@^5.0.0, glob-parent@^5.1.1, glob-parent@^5.1.2, glob-parent@~5.1.2: +======= glob-parent@^5.1.2, glob-parent@~5.1.2: +>>>>>>> upstream/main version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -11030,7 +11077,11 @@ node-abort-controller@^3.0.1: resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== +<<<<<<< HEAD +node-fetch@2.6.7, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.5, node-fetch@^2.6.7: +======= node-fetch@^2.6.0, node-fetch@^2.6.1: +>>>>>>> upstream/main version "2.6.7" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==