diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx index 8a328b70b4396d..328f1d1fa66881 100644 --- a/components/dashboard/src/projects/NewProject.tsx +++ b/components/dashboard/src/projects/NewProject.tsx @@ -23,9 +23,9 @@ export default function NewProject() { const location = useLocation(); const history = useHistory(); const { teams } = useContext(TeamsContext); - const { user } = useContext(UserContext); + const { user, setUser } = useContext(UserContext); - const [provider, setProvider] = useState("github.com"); + const [provider, setProvider] = useState(); const [reposInAccounts, setReposInAccounts] = useState([]); const [repoSearchFilter, setRepoSearchFilter] = useState(""); const [selectedAccount, setSelectedAccount] = useState(undefined); @@ -37,6 +37,16 @@ export default function NewProject() { const [showNewTeam, setShowNewTeam] = useState(false); const [loaded, setLoaded] = useState(false); + useEffect(() => { + if (user && provider === undefined) { + if (user.identities.find(i => i.authProviderId === "Public-GitLab")) { + setProvider("gitlab.com"); + } else if (user.identities.find(i => i.authProviderId === "Public-GitHub")) { + setProvider("github.com"); + } + } + }, [user]); + useEffect(() => { const params = new URLSearchParams(location.search); const teamParam = params.get("team"); @@ -45,16 +55,6 @@ export default function NewProject() { const team = teams?.find(t => t.slug === teamParam); setSelectedTeamOrUser(team); } - - (async () => { - updateOrgsState(); - const repos = await updateReposInAccounts(); - const first = repos[0]; - if (first) { - setSelectedAccount(first.account); - } - setLoaded(true); - })(); }, []); useEffect(() => { @@ -77,9 +77,27 @@ export default function NewProject() { setRepoSearchFilter(""); }, [selectedAccount]); + useEffect(() => { + if (!provider) { + return; + } + (async () => { + updateOrgsState(); + const repos = await updateReposInAccounts(); + const first = repos[0]; + if (first) { + setSelectedAccount(first.account); + } + setLoaded(true); + })(); + }, [provider]); + const isGitHub = () => provider === "github.com"; const updateReposInAccounts = async (installationId?: string) => { + if (!provider) { + return []; + } try { const repos = await getGitpodService().server.getProviderRepositoriesForUser({ provider, hints: { installationId } }); setReposInAccounts(repos); @@ -96,7 +114,7 @@ export default function NewProject() { } const updateOrgsState = async () => { - if (isGitHub()) { + if (provider && isGitHub()) { try { const ghToken = await getToken(provider); setNoOrgs(ghToken?.scopes.includes("read:org") !== true); @@ -130,6 +148,9 @@ export default function NewProject() { } const createProject = async (teamOrUser: Team | User, selectedRepo: string) => { + if (!provider) { + return; + } const repo = reposInAccounts.find(r => r.account === selectedAccount && r.name === selectedRepo); if (!repo) { console.error("No repo selected!") @@ -156,7 +177,6 @@ export default function NewProject() { return splitted.shift() && splitted.join("/"); } - const reposToRender = Array.from(reposInAccounts).filter(r => r.account === selectedAccount && r.name.includes(repoSearchFilter)); const accounts = new Map(); reposInAccounts.forEach(r => { if (!accounts.has(r.account)) accounts.set(r.account, { avatarUrl: r.accountAvatarUrl }) }); @@ -193,33 +213,40 @@ export default function NewProject() { const renderSelectRepository = () => { + const noReposAvailable = reposInAccounts.length === 0; + const filteredRepos = Array.from(reposInAccounts).filter(r => r.account === selectedAccount && r.name.includes(repoSearchFilter)); const icon = selectedAccount && accounts.get(selectedAccount)?.avatarUrl; - const renderRepos = () => (
-
- -
- - - -
-
-
- - setRepoSearchFilter(e.target.value)}> + const renderRepos = () => (<> +
+
+ +
+ {icon && ( + + )} + + +
+
+ {filteredRepos.length > 0 && ( +
+ + setRepoSearchFilter(e.target.value)}> +
+ )}
-
-
- {reposToRender.length > 0 && ( -
- {reposToRender.map(r => ( -
- -
-
{toSimpleName(r.name)}
-

Updated {moment(r.updatedAt).fromNow()}

-
+
+ {filteredRepos.length > 0 && ( +
+ {filteredRepos.map((r, index) => ( +
+ +
+
{toSimpleName(r.name)}
+

Updated {moment(r.updatedAt).fromNow()}

+
{!r.inUse ? ( @@ -229,54 +256,73 @@ export default function NewProject() { )}
+
+ ))} +
+ )} + {!noReposAvailable && filteredRepos.length === 0 && ( +

No Results

+ )} + {loaded && noReposAvailable && isGitHub() && (
+
+ +

+ No Access +

+ + Authorize GitHub (github.com) or select a different account. + +
+ +
+
)} +
+ +
+ {isGitHub() && ( + - )} - {reposToRender.length === 0 && ( -

not found

- )} -
- -
- - {isGitHub() && noOrgs && ( -
- Missing organizations? grantReadOrgPermissions()} className="text-gray-400 underline underline-thickness-thin underline-offset-small hover:text-gray-600">Grant permissions + )} + + ); + + const renderEmptyState = () => (
+
+
+
+

+ Loading ... +

- )} -
-
-
); - - const renderEmptyState = () => (
-
-
- -

- No Access -

- - Authorize GitHub (github.com) or select a different account. - -
-
) - const empty = reposInAccounts.length === 0; - - const onGitProviderSeleted = (host: string) => { + const onGitProviderSeleted = async (host: string, updateUser?: boolean) => { + if (updateUser) { + setUser(await getGitpodService().server.getLoggedInUser()); + } setShowGitProviders(false); setProvider(host); } - return (<> - {(loaded && empty) ? renderEmptyState() : (showGitProviders ? () : renderRepos())} - ) + if (!loaded) { + return renderEmptyState(); + } + + if (showGitProviders) { + return (); + } + + return renderRepos(); }; const renderSelectTeam = () => { @@ -322,7 +368,7 @@ export default function NewProject() { return (

New Project

-

Select a git repository.

+

Select a git repository on {provider}.

{!selectedRepo && renderSelectRepository()} @@ -335,7 +381,7 @@ export default function NewProject() { } function GitProviders(props: { - onHostSelected: (host: string) => void + onHostSelected: (host: string, updateUser?: boolean) => void }) { const [authProviders, setAuthProviders] = useState([]); @@ -355,8 +401,8 @@ function GitProviders(props: { await openAuthorizeWindow({ host: ap.host, scopes: ap.requirements?.default, - onSuccess: () => { - props.onHostSelected(ap.host); + onSuccess: async () => { + props.onHostSelected(ap.host, true); }, onError: (error) => { console.log(error); diff --git a/components/dashboard/src/projects/Prebuild.tsx b/components/dashboard/src/projects/Prebuild.tsx index 26d9ed169c4454..1613b0317b8b5c 100644 --- a/components/dashboard/src/projects/Prebuild.tsx +++ b/components/dashboard/src/projects/Prebuild.tsx @@ -16,12 +16,14 @@ import PrebuildLogs from "../components/PrebuildLogs"; import { shortCommitMessage } from "./render-utils"; export default function () { - const { teams } = useContext(TeamsContext); const location = useLocation(); + + const { teams } = useContext(TeamsContext); + const team = getCurrentTeam(location, teams); + const match = useRouteMatch<{ team: string, project: string, prebuildId: string }>("/:team/:project/:prebuildId"); const projectName = match?.params?.project; const prebuildId = match?.params?.prebuildId; - const team = getCurrentTeam(location, teams); const [ prebuild, setPrebuild ] = useState(); @@ -44,7 +46,7 @@ export default function () { }); setPrebuild(prebuilds[0]); })(); - }, [ teams, team ]); + }, [ teams ]); const renderTitle = () => { if (!prebuild) { diff --git a/components/dashboard/src/projects/Prebuilds.tsx b/components/dashboard/src/projects/Prebuilds.tsx index 69ae8c18d3fbc2..ad7414f8252f12 100644 --- a/components/dashboard/src/projects/Prebuilds.tsx +++ b/components/dashboard/src/projects/Prebuilds.tsx @@ -18,11 +18,13 @@ import { shortCommitMessage } from "./render-utils"; export default function () { const history = useHistory(); - const { teams } = useContext(TeamsContext); const location = useLocation(); + + const { teams } = useContext(TeamsContext); + const team = getCurrentTeam(location, teams); + const match = useRouteMatch<{ team: string, resource: string }>("/:team/:resource"); const projectName = match?.params?.resource; - const team = getCurrentTeam(location, teams); // @ts-ignore const [project, setProject] = useState(); @@ -55,7 +57,7 @@ export default function () { } } })(); - }, [ teams, team ]); + }, [ teams ]); const prebuildContextMenu = (p: PrebuildInfo) => { const running = p.status === "building"; diff --git a/components/dashboard/src/projects/Project.tsx b/components/dashboard/src/projects/Project.tsx index 242081c7db5b20..c06298100abe5f 100644 --- a/components/dashboard/src/projects/Project.tsx +++ b/components/dashboard/src/projects/Project.tsx @@ -18,11 +18,13 @@ import { shortCommitMessage } from "./render-utils"; export default function () { const history = useHistory(); - const { teams } = useContext(TeamsContext); const location = useLocation(); + + const { teams } = useContext(TeamsContext); + const team = getCurrentTeam(location, teams); + const match = useRouteMatch<{ team: string, resource: string }>("/:team/:resource"); const projectName = match?.params?.resource; - const team = getCurrentTeam(location, teams); const [project, setProject] = useState(); @@ -34,7 +36,7 @@ export default function () { useEffect(() => { updateProject(); - }, [ teams, team ]); + }, [ teams ]); const updateProject = async () => { if (!teams || !projectName) { @@ -87,7 +89,7 @@ export default function () { // TODO(at): this need to be revised once prebuild events are integrated return; } - if (!team || !project) { + if (!project) { return; } prebuildLoaders.add(branch.name); diff --git a/components/dashboard/src/projects/Projects.tsx b/components/dashboard/src/projects/Projects.tsx index 1374086faf7c32..f89c6bfb36ab7e 100644 --- a/components/dashboard/src/projects/Projects.tsx +++ b/components/dashboard/src/projects/Projects.tsx @@ -32,7 +32,7 @@ export default function () { useEffect(() => { updateProjects(); - }, [ teams, team ]); + }, [ teams ]); const updateProjects = async () => { if (!teams) { diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 9c2c7851a16699..2653cbc3f7ecc9 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -53,6 +53,7 @@ import { BlockedUserFilter } from "../../src/auth/blocked-user-filter"; import { EMailDomainService, EMailDomainServiceImpl } from "./auth/email-domain-service"; import { UserDeletionServiceEE } from "./user/user-deletion-service"; import { GitHubAppSupport } from "./github/github-app-support"; +import { GitLabAppSupport } from "./gitlab/gitlab-app-support"; export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { rebind(Server).to(ServerEE).inSingletonScope(); @@ -71,6 +72,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(GithubAppRules).toSelf().inSingletonScope(); bind(PrebuildStatusMaintainer).toSelf().inSingletonScope(); bind(GitLabApp).toSelf().inSingletonScope(); + bind(GitLabAppSupport).toSelf().inSingletonScope(); bind(BitbucketApp).toSelf().inSingletonScope(); bind(LicenseEvaluator).toSelf().inSingletonScope(); diff --git a/components/server/ee/src/github/github-app-support.ts b/components/server/ee/src/github/github-app-support.ts index a13c7f45715084..369f93ce8fb7ca 100644 --- a/components/server/ee/src/github/github-app-support.ts +++ b/components/server/ee/src/github/github-app-support.ts @@ -59,7 +59,7 @@ export class GitHubAppSupport { name: r.name, cloneUrl: r.clone_url, account: r.owner.login, - accountAvatarUrl: r.owner.avatar_url, + accountAvatarUrl: r.owner?.avatar_url, updatedAt: r.updated_at, installationId: installation.data.id, installationUpdatedAt: installation.data.updated_at diff --git a/components/server/ee/src/gitlab/gitlab-app-support.ts b/components/server/ee/src/gitlab/gitlab-app-support.ts new file mode 100644 index 00000000000000..557c1e33496623 --- /dev/null +++ b/components/server/ee/src/gitlab/gitlab-app-support.ts @@ -0,0 +1,51 @@ +/** + * 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 { 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 { Gitlab } from "@gitbeaker/node"; + +@injectable() +export class GitLabAppSupport { + + @inject(UserDB) protected readonly userDB: UserDB; + @inject(TokenProvider) protected readonly tokenProvider: TokenProvider; + + async getProviderRepositoriesForUser(params: { user: User, provider: string, hints?: object }): Promise { + const token = await this.tokenProvider.getTokenForHost(params.user, "gitlab.com"); + const oauthToken = token.value; + const api = new Gitlab({ oauthToken }); + + const result: ProviderRepository[] = []; + + // cf. https://docs.gitlab.com/ee/api/projects.html#list-all-projects + // we are listing only those projects with access level of maintainers. + // also cf. https://docs.gitlab.com/ee/api/members.html#valid-access-levels + // + const projectsWithAccess = await api.Projects.all({ min_access_level: "40", perPage: 100 }); + for (const project of projectsWithAccess) { + const anyProject = project as any; + const fullPath = anyProject.path_with_namespace as string; + const cloneUrl = anyProject.http_url_to_repo as string; + const updatedAt = anyProject.last_activity_at as string; + const accountAvatarUrl = anyProject.owner?.avatar_url as string; + + result.push({ + name: project.name, + account: fullPath.split("/")[0], + cloneUrl, + updatedAt, + accountAvatarUrl, + // inUse: // todo(at) compute usage via ProjectHooks API + }) + } + + return result; + } + +} \ No newline at end of file diff --git a/components/server/ee/src/prebuilds/github-app.ts b/components/server/ee/src/prebuilds/github-app.ts index c28de3fed714c9..fdcaceb75aeb73 100644 --- a/components/server/ee/src/prebuilds/github-app.ts +++ b/components/server/ee/src/prebuilds/github-app.ts @@ -352,8 +352,8 @@ export class GithubApp { } } } - } + protected async findInstallationOwner(installationId: number): Promise<{user: User, project?: Project} | undefined> { // Legacy mode // diff --git a/components/server/ee/src/prebuilds/gitlab-app.ts b/components/server/ee/src/prebuilds/gitlab-app.ts index 11518b7d3745fb..2620cd8ef209f8 100644 --- a/components/server/ee/src/prebuilds/gitlab-app.ts +++ b/components/server/ee/src/prebuilds/gitlab-app.ts @@ -6,8 +6,8 @@ import * as express from 'express'; import { postConstruct, injectable, inject } from 'inversify'; -import { UserDB } from '@gitpod/gitpod-db/lib'; -import { User } from '@gitpod/gitpod-protocol'; +import { ProjectDB, TeamDB, UserDB } from '@gitpod/gitpod-db/lib'; +import { Project, User } from '@gitpod/gitpod-protocol'; import { PrebuildManager } from '../prebuilds/prebuild-manager'; import { TraceContext } from '@gitpod/gitpod-protocol/lib/util/tracing'; import { StartPrebuildResult } from './prebuild-manager'; @@ -23,6 +23,8 @@ export class GitLabApp { @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/gitlab/'; @@ -36,13 +38,18 @@ export class GitLabApp { const span = TraceContext.startSpan("GitLapApp.handleEvent", {}); span.setTag("request", context); log.debug("GitLab push hook received", { event, context }); - const user = await this.findUser({span},context, req); + let user: User | undefined; + try { + user = await this.findUser({ span }, context, req); + } catch (error) { + log.error("Cannot find user.", error, { req }) + } if (!user) { res.statusCode = 503; res.send(); return; } - this.handlePushHook({span},context, user); + this.handlePushHook({ span }, context, user); } else { log.debug("Unknown GitLab event received", { event }); } @@ -76,7 +83,7 @@ export class GitLabApp { } if (token.token.scopes.indexOf(GitlabService.PREBUILD_TOKEN_SCOPE) === -1 || token.token.scopes.indexOf(context.repository.git_http_url) === -1) { - throw new Error(`The provided token is not valid for the repository ${context.repository.git_http_url}.`); + throw new Error(`The provided token is not valid for the repository ${context.repository.git_http_url}.`); } return user; } finally { @@ -97,8 +104,19 @@ export class GitLabApp { } log.debug({ userId: user.id }, "GitLab push hook: Starting prebuild", { body, contextURL }); - // todo@alex: add branch and project args - const ws = await this.prebuildManager.startPrebuild({ span }, { user, contextURL, cloneURL: body.repository.git_http_url, commit: body.after }); + + const cloneURL = body.repository.git_http_url; + const branch = this.getBranchFromRef(body.ref); + const projectOwner = await this.findProjectOwner(cloneURL); + + const ws = await this.prebuildManager.startPrebuild({ span }, { + user: projectOwner?.user || user, + project: projectOwner?.project, + contextURL, + cloneURL, + commit: body.after, + branch, + }); return ws; } finally { @@ -106,6 +124,21 @@ export class GitLabApp { } } + protected async findProjectOwner(cloneURL: string): Promise<{ user: User, project?: Project } | undefined> { + const project = await this.projectDB.findProjectByCloneUrl(cloneURL); + if (project) { + const owner = !!project.userId + ? { userId: project.userId } + : (await this.teamDB.findMembersByTeam(project.teamId || '')).filter(m => m.role === "owner")[0]; + if (owner) { + const user = await this.userDB.findUserById(owner.userId); + if (user) { + return { user, project }; + } + } + } + } + protected createContextUrl(body: GitLabPushHook) { const repoUrl = body.repository.git_http_url; const contextURL = `${repoUrl.substr(0, repoUrl.length - 4)}/-/tree${body.ref.substr('refs/head/'.length)}`; @@ -115,16 +148,41 @@ export class GitLabApp { 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 GitLabPushHook { object_kind: 'push'; before: string; after: string; // commit - ref: string; // branch + ref: string; // e.g. "refs/heads/master" + user_avatar: string; + user_name: string; + project: GitLabProject; repository: GitLabRepository; } interface GitLabRepository { - git_http_url: string; //e.g. http://example.com/mike/diaspora.git + name: string, + git_http_url: string; // e.g. http://example.com/mike/diaspora.git + visibility_level: number, +} + +interface GitLabProject { + id: number, + namespace: string, + name: string, + path_with_namespace: string, // e.g. "mike/diaspora" + git_http_url: string; // e.g. http://example.com/mike/diaspora.git + web_url: string; // e.g. http://example.com/mike/diaspora + visibility_level: number, + avatar_url: string | null, } \ No newline at end of file diff --git a/components/server/ee/src/prebuilds/gitlab-service.ts b/components/server/ee/src/prebuilds/gitlab-service.ts index 134e247d1200cd..3dcf9f07340a43 100644 --- a/components/server/ee/src/prebuilds/gitlab-service.ts +++ b/components/server/ee/src/prebuilds/gitlab-service.ts @@ -13,6 +13,7 @@ import { GitLabApp } from "./gitlab-app"; import { Env } from "../../../src/env"; import { TokenService } from "../../../src/user/token-service"; import { GitlabContextParser } from "../../../src/gitlab/gitlab-context-parser"; +import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; @injectable() export class GitlabService extends RepositoryService { @@ -26,36 +27,43 @@ export class GitlabService extends RepositoryService { @inject(GitlabContextParser) protected gitlabContextParser: GitlabContextParser; async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise { - const { host } = await this.gitlabContextParser.parseURL(user, cloneUrl); - return host === this.config.host; - } - - async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise { + const { host, owner, repoName } = await this.gitlabContextParser.parseURL(user, cloneUrl); + if (host !== this.config.host) { + return false; + } const api = await this.api.create(user); - const { owner, repoName } = await this.gitlabContextParser.parseURL(user, cloneUrl); const response = (await api.Projects.show(`${owner}/${repoName}`)) as unknown as GitLab.Project; if (GitLab.ApiError.is(response)) { throw response; } - const hooks = (await api.ProjectHooks.all(response.id)) as unknown as GitLab.ProjectHook[]; + // one need to have at least the access level of a maintainer (40) in order to install webhooks on a project + // cf. https://docs.gitlab.com/ee/api/members.html#valid-access-levels + return GitLab.Permissions.hasMaintainerAccess(response); + } + + async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise { + const api = await this.api.create(user); + const { owner, repoName } = await this.gitlabContextParser.parseURL(user, cloneUrl); + const gitlabProjectId = `${owner}/${repoName}`; + const hooks = (await api.ProjectHooks.all(gitlabProjectId)) as unknown as GitLab.ProjectHook[]; if (GitLab.ApiError.is(hooks)) { throw hooks; } let existingProps: any = {}; for (const hook of hooks) { if (hook.url === this.getHookUrl()) { - console.log('Deleting existing hook'); + log.info('Deleting existing hook'); existingProps = hook - await api.ProjectHooks.remove(response.id, hook.id); + await api.ProjectHooks.remove(gitlabProjectId, hook.id); } } const tokenEntry = await this.tokenService.createGitpodToken(user, GitlabService.PREBUILD_TOKEN_SCOPE, cloneUrl); - await api.ProjectHooks.add(response.id, this.getHookUrl(), >{ + await api.ProjectHooks.add(gitlabProjectId, this.getHookUrl(), >{ ...existingProps, push_events: true, token: user.id+'|'+tokenEntry.token.value }); - console.log('Installed Webhook for ' + cloneUrl); + log.info('Installed Webhook for ' + cloneUrl, { cloneUrl, userId: user.id }); } async canAccessHeadlessLogs(user: User, context: WorkspaceContext): Promise { @@ -67,7 +75,7 @@ export class GitlabService extends RepositoryService { try { // If we can "see" a project we are allowed to access it's headless logs const api = await this.api.create(user); - const response = (await api.Projects.show(`${owner}/${repoName}`)) as unknown as GitLab.Project; + const response = await api.Projects.show(`${owner}/${repoName}`); if (GitLab.ApiError.is(response)) { return false; } diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 10c528f095b81f..01ff2be9d67d01 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -40,6 +40,7 @@ import { Chargebee as chargebee } from '@gitpod/gitpod-payment-endpoint/lib/char import { EnvEE } from "../env"; import { GitHubAppSupport } from "../github/github-app-support"; +import { GitLabAppSupport } from "../gitlab/gitlab-app-support"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { @@ -68,6 +69,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { const user = this.checkAndBlockUser("getProviderRepositoriesForUser"); - const repositories = await this.githubAppSupport.getProviderRepositoriesForUser({ user, ...params }); + const repositories: ProviderRepository[] = []; + if (params.provider === "github.com") { + repositories.push(...(await this.githubAppSupport.getProviderRepositoriesForUser({ user, ...params }))); + } else if (params.provider === "gitlab.com") { + repositories.push(...(await this.gitLabAppSupport.getProviderRepositoriesForUser({ user, ...params }))); + } else { + log.info({ userId: user.id }, `Unsupported provider: "${params.provider}"`, { params }); + } const projects = await this.projectsService.getProjectsByCloneUrls(repositories.map(r => r.cloneUrl)); const cloneUrlsInUse = new Set(projects.map(p => p.cloneUrl)); diff --git a/components/server/src/gitlab/api.ts b/components/server/src/gitlab/api.ts index 4844d3db527c92..923846d7c0ce95 100644 --- a/components/server/src/gitlab/api.ts +++ b/components/server/src/gitlab/api.ts @@ -204,7 +204,17 @@ export namespace GitLab { private_profile: boolean; } export interface Permissions { - project_access: { + project_access?: { + /**` + * 10 => Guest access + * 20 => Reporter access + * 30 => Developer access + * 40 => Maintainer access + * 50 => Owner accesss + `*/ + access_level: number; + }, + group_access?: { /**` * 10 => Guest access * 20 => Reporter access @@ -217,7 +227,22 @@ export namespace GitLab { } export namespace Permissions { export function hasWriteAccess(repo: Project): boolean { - return repo.permissions.project_access.access_level >= 30; + if (repo.permissions.project_access) { + return repo.permissions.project_access.access_level >= 30; + } + if (repo.permissions.group_access) { + return repo.permissions.group_access.access_level >= 30; + } + return false; + } + export function hasMaintainerAccess(repo: Project): boolean { + if (repo.permissions.project_access) { + return repo.permissions.project_access.access_level >= 40; + } + if (repo.permissions.group_access) { + return repo.permissions.group_access.access_level >= 40; + } + return false; } } export interface Commit { @@ -225,12 +250,15 @@ export namespace GitLab { short_id: string; title: string; message: string; - parent_ids: string[]; + parent_ids: string[] | null; + author_name: string; + authored_date: string; } export interface Branch { commit: Commit; name: string; default: boolean; + web_url: string; } export interface Tag { name: string, diff --git a/components/server/src/gitlab/gitlab-repository-provider.ts b/components/server/src/gitlab/gitlab-repository-provider.ts index 1d2eaff4e93dae..0ecbb256021c20 100644 --- a/components/server/src/gitlab/gitlab-repository-provider.ts +++ b/components/server/src/gitlab/gitlab-repository-provider.ts @@ -11,6 +11,8 @@ import { GitLabApi, GitLab } from "./api"; import { RepositoryProvider } from '../repohost/repository-provider'; import { parseRepoUrl } from '../repohost/repo-url'; +import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; + @injectable() export class GitlabRepositoryProvider implements RepositoryProvider { @inject(GitLabApi) protected readonly gitlab: GitLabApi; @@ -25,23 +27,71 @@ export class GitlabRepositoryProvider implements RepositoryProvider { const cloneUrl = response.http_url_to_repo; const description = response.default_branch; const host = parseRepoUrl(cloneUrl)!.host; - const avatarUrl = response.owner.avatar_url || undefined; + const avatarUrl = response.owner?.avatar_url || undefined; const webUrl = response.web_url; - return { host, owner, name, cloneUrl, description, avatarUrl, webUrl }; + const defaultBranch = response.default_branch + return { host, owner, name, cloneUrl, description, avatarUrl, webUrl, defaultBranch }; } async getBranch(user: User, owner: string, repo: string, branch: string): Promise { - // todo - throw new Error("not implemented"); + const response = await this.gitlab.run(user, async g => { + return g.Branches.show(`${owner}/${repo}`, branch); + }); + if (GitLab.ApiError.is(response)) { + throw response; + } + return { + htmlUrl: response.web_url, + name: response.name, + commit: { + sha: response.commit.id, + author: response.commit.author_name, + authorAvatarUrl: "", // TODO(at) fetch avatar URL + authorDate: response.commit.authored_date, + commitMessage: response.commit.message || "missing commit message", + } + }; } async getBranches(user: User, owner: string, repo: string): Promise { - // todo - return []; + const branches: Branch[] = []; + const response = await this.gitlab.run(user, async g => { + return g.Branches.all(`${owner}/${repo}`); + }); + if (GitLab.ApiError.is(response)) { + throw response; + } + for (const b of response) { + branches.push({ + htmlUrl: b.web_url, + name: b.name, + commit: { + sha: b.commit.id, + author: b.commit.author_name, + authorDate: b.commit.authored_date, + authorAvatarUrl: "", // TODO(at) fetch avatar URL + commitMessage: b.commit.message || "missing commit message", + } + }) + } + return branches; } async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise { - // todo - return undefined; + const response = await this.gitlab.run(user, async g => { + return g.Commits.show(`${owner}/${repo}`, ref); + }); + if (GitLab.ApiError.is(response)) { + // throw response; + log.debug("Failed to fetch commit.", { response }); + return undefined; + } + return { + sha: response.id, + author: response.author_name, + authorDate: response.authored_date, + commitMessage: response.message || "missing commit message", + authorAvatarUrl: "", + }; } } diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts index afeaa1fb49f4dc..f3996a04391cac 100644 --- a/components/server/src/projects/projects-service.ts +++ b/components/server/src/projects/projects-service.ts @@ -5,7 +5,7 @@ */ import { inject, injectable } from "inversify"; -import { DBWithTracing, ProjectDB, TeamDB, TracedWorkspaceDB, WorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { DBWithTracing, ProjectDB, TeamDB, TracedWorkspaceDB, UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib"; import { Branch, CommitInfo, CreateProjectParams, FindPrebuildsParams, PrebuildInfo, PrebuiltWorkspace, Project, ProjectConfig, User } from "@gitpod/gitpod-protocol"; import { HostContextProvider } from "../auth/host-context-provider"; import { parseRepoUrl } from "../repohost"; @@ -16,6 +16,7 @@ export class ProjectsService { @inject(ProjectDB) protected readonly projectDB: ProjectDB; @inject(TeamDB) protected readonly teamDB: TeamDB; + @inject(UserDB) protected readonly userDB: UserDB; @inject(TracedWorkspaceDB) protected readonly workspaceDb: DBWithTracing; @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; @@ -92,7 +93,32 @@ export class ProjectsService { ...(!!userId ? { userId } : { teamId }), appInstallationId }); - return this.projectDB.storeProject(project); + await this.projectDB.storeProject(project); + await this.onDidCreateProject(project); + return project; + } + + protected async onDidCreateProject(project: Project) { + let { userId, teamId, cloneUrl } = project; + const parsedUrl = parseRepoUrl(project.cloneUrl); + if ("gitlab.com" === parsedUrl?.host) { + const repositoryService = this.hostContextProvider.get(parsedUrl?.host)?.services?.repositoryService; + if (repositoryService) { + if (teamId) { + const owner = (await this.teamDB.findMembersByTeam(teamId)).find(m => m.role === "owner"); + userId = owner?.userId; + } + const user = userId && await this.userDB.findUserById(userId); + if (user) { + if (await repositoryService.canInstallAutomatedPrebuilds(user, cloneUrl)) { + log.info("Update prebuild installation for project.", { cloneUrl, teamId, userId }); + await repositoryService.installAutomatedPrebuilds(user, cloneUrl); + } + } else { + log.error("Cannot find user for project.", { cloneUrl }) + } + } + } } async deleteProject(projectId: string): Promise { diff --git a/yarn.lock b/yarn.lock index 24aac5a8d8aea7..59d8a0bec29788 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4661,7 +4661,7 @@ "@types/history" "*" "@types/react" "*" -"@types/react@*", "@types/react@^17.0.0": +"@types/react@*", "@types/react@17.0.0", "@types/react@^17.0.0": version "17.0.0" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw== @@ -14875,7 +14875,7 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.12.1, nan@^2.13.2, nan@^2.9.2: +nan@2.14.1, nan@^2.12.1, nan@^2.13.2, nan@^2.14.0, nan@^2.9.2: version "2.14.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -15447,6 +15447,13 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +oniguruma@7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/oniguruma/-/oniguruma-7.2.1.tgz#51775834f7819b6e31aa878706aa7f65ad16b07f" + integrity sha512-WPS/e1uzhswPtJSe+Zls/kAj27+lEqZjCmRSjnYk/Z4L2Mu+lJC2JWtkZhPJe4kZeTQfz7ClcLyXlI4J68MG2w== + dependencies: + nan "^2.14.0" + open@^7.0.2: version "7.4.2" resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" @@ -17585,7 +17592,7 @@ react-dev-utils@^11.0.3: strip-ansi "6.0.0" text-table "0.2.0" -react-dom@^17.0.1: +react-dom@17.0.1, react-dom@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug== @@ -17714,7 +17721,7 @@ react-scripts@^4.0.3: optionalDependencies: fsevents "^2.1.3" -react@^17.0.1: +react@17.0.1, react@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w== @@ -20992,11 +20999,24 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019" integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw== -vscode-jsonrpc@^5.0.0: +vscode-jsonrpc@^5.0.0, vscode-jsonrpc@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz#9bab9c330d89f43fc8c1e8702b5c36e058a01794" integrity sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A== +vscode-languageserver-protocol@3.15.3: + version "3.15.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.15.3.tgz#3fa9a0702d742cf7883cb6182a6212fcd0a1d8bb" + integrity sha512-zrMuwHOAQRhjDSnflWdJG+O2ztMWss8GqUUB8dXLR/FPenwkiBNkMIJJYfSN6sgskvsF0rHAoBowNQfbyZnnvw== + dependencies: + vscode-jsonrpc "^5.0.1" + vscode-languageserver-types "3.15.1" + +vscode-languageserver-types@3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz#17be71d78d2f6236d414f0001ce1ef4d23e6b6de" + integrity sha512-+a9MPUQrNGRrGU630OGbYVQ+11iOIovjCkqxajPa9w57Sd5ruK8WQNsslzpa0x/QJqC8kRc2DUxWjIFwoNm4ZQ== + vscode-uri@^1.0.1: version "1.0.8" resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.8.tgz#9769aaececae4026fb6e22359cb38946580ded59"