diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 711c87c0f04214..65ddc5eebc6364 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -36,6 +36,7 @@ const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ './ const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects')); const Project = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Project')); const Prebuilds = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuilds')); +const Prebuild = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuild')); const Settings = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Settings')); const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './prebuilds/InstallGitHubApp')); const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer')); @@ -199,24 +200,24 @@ function App() { - { - const { maybeProject, subResource } = props.match.params; + { + const { maybeProject, resourceOrPrebuild } = props.match.params; if (maybeProject === "projects") { return ; } if (maybeProject === "members") { return ; } - if (subResource === "configure") { + if (resourceOrPrebuild === "configure") { return ; } - if (subResource === "prebuilds") { + if (resourceOrPrebuild === "prebuilds") { return ; } - if (subResource === "settings") { + if (resourceOrPrebuild === "settings") { return ; } - return ; + return resourceOrPrebuild ? : ; }} /> )} ) { - const all = [entry.link, ...(entry.alternatives||[])]; - const path = location.pathname.toLowerCase(); - return all.some(n => n === path || n+'/' === path); -} export default function Menu() { const { user } = useContext(UserContext); @@ -39,14 +35,26 @@ export default function Menu() { const history = useHistory(); const location = useLocation(); - const match = useRouteMatch<{ team: string, resource: string }>("/:team/:resource"); + const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/:segment1/:segment2?/:segment3?"); const projectName = (() => { - const resource = match?.params?.resource; - if (resource !== "projects" && resource !== "members") { + const resource = match?.params?.segment2; + if (resource && !["projects", "members", "users", "workspaces"].includes(resource)) { + return resource; + } + })(); + const prebuildId = (() => { + const resource = projectName && match?.params?.segment3; + if (resource !== "prebuilds" && resource !== "settings" && resource !== "configure") { return resource; } })(); + function isSelected(entry: Entry, location: Location) { + const all = [entry.link, ...(entry.alternatives||[])].map(l => l.toLowerCase()); + const path = location.pathname.toLowerCase(); + return all.some(n => n === path || n+'/' === path); + } + const userFullName = user?.fullName || user?.name || '...'; const showTeamsUI = user?.rolesOrPermissions?.includes('teams-and-projects'); const team = getCurrentTeam(location, teams); @@ -135,7 +143,6 @@ export default function Menu() {
- {team?.name || userFullName}
@@ -171,14 +178,22 @@ export default function Menu() { onClick: () => history.push("/teams/new"), } ]}> -
- +
+
{ projectName && ( +
+ + {projectName} + +
+ )} + { prebuildId && (
- {projectName} + + {prebuildId}
)}
@@ -237,8 +252,8 @@ export default function Menu() { - {!isMinimalUI && showTeamsUI &&
- {leftMenu.map(entry => )} + {!isMinimalUI && showTeamsUI && !prebuildId &&
+ {leftMenu.map((entry: Entry) => )}
} {showTeamsUI && } diff --git a/components/dashboard/src/components/DropDown.tsx b/components/dashboard/src/components/DropDown.tsx index d59878baf02c81..1a31919b2ef69f 100644 --- a/components/dashboard/src/components/DropDown.tsx +++ b/components/dashboard/src/components/DropDown.tsx @@ -11,10 +11,12 @@ export interface DropDownProps { prefix?: string; contextMenuWidth?: string; activeEntry?: string, - entries: { - title: string, - onClick: ()=>void - }[]; + entries: DropDownEntry[]; +} + +export interface DropDownEntry { + title: string, + onClick: ()=>void } function Arrow(props: {up: boolean}) { diff --git a/components/dashboard/src/components/Header.tsx b/components/dashboard/src/components/Header.tsx index 95bbc660d54d0f..7f8aef97ba3807 100644 --- a/components/dashboard/src/components/Header.tsx +++ b/components/dashboard/src/components/Header.tsx @@ -7,16 +7,16 @@ import Separator from "./Separator"; export interface HeaderProps { - title: string; - subtitle: string; + title: string | React.ReactElement; + subtitle: string | React.ReactElement; } export default function Header(p: HeaderProps) { return
-

{p.title}

-

{p.subtitle}

+ {typeof p.title === "string" ? (

{p.title}

) : p.title} + {typeof p.subtitle === "string" ? (

{p.subtitle}

) : p.subtitle}
diff --git a/components/dashboard/src/components/PrebuildLogs.tsx b/components/dashboard/src/components/PrebuildLogs.tsx index f57a3306f8407f..55a00112f9fa1f 100644 --- a/components/dashboard/src/components/PrebuildLogs.tsx +++ b/components/dashboard/src/components/PrebuildLogs.tsx @@ -6,7 +6,7 @@ import EventEmitter from "events"; import React, { Suspense, useEffect, useState } from "react"; -import { Workspace, WorkspaceInstance, DisposableCollection, WorkspaceImageBuild, GitpodServer, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol"; +import { Workspace, WorkspaceInstance, DisposableCollection, WorkspaceImageBuild, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol"; import { getGitpodService } from "../service/service"; const WorkspaceLogs = React.lazy(() => import('./WorkspaceLogs')); @@ -15,8 +15,7 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { const [ workspace, setWorkspace ] = useState(); const [ workspaceInstance, setWorkspaceInstance ] = useState(); const [ error, setError ] = useState(); - const logsEmitter = new EventEmitter(); - const service = getGitpodService(); + const [ logsEmitter ] = useState(new EventEmitter()); useEffect(() => { const disposables = new DisposableCollection(); @@ -25,12 +24,12 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { return; } try { - const info = await service.server.getWorkspace(props.workspaceId); + const info = await getGitpodService().server.getWorkspace(props.workspaceId); if (info.latestInstance) { setWorkspace(info.workspace); setWorkspaceInstance(info.latestInstance); } - disposables.push(service.registerClient({ + disposables.push(getGitpodService().registerClient({ onInstanceUpdate: setWorkspaceInstance, onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => { if (!content) { @@ -40,7 +39,7 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { }, })); if (info.latestInstance) { - disposables.push(watchHeadlessLogs(service.server, info.latestInstance.id, chunk => { + disposables.push(watchHeadlessLogs(info.latestInstance.id, chunk => { logsEmitter.emit('logs', chunk); }, async () => workspaceInstance?.status.phase === 'stopped')); } @@ -64,7 +63,7 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { // Preparing means that we haven't actually started the workspace instance just yet, but rather // are still preparing for launch. This means we're building the Docker image for the workspace. case "preparing": - service.server.watchWorkspaceImageBuildLogs(workspace!.id); + getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); break; // Pending means the workspace does not yet consume resources in the cluster, but rather is looking for @@ -100,7 +99,7 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { // Stopped means the workspace ended regularly because it was shut down. case "stopped": - service.server.watchWorkspaceImageBuildLogs(workspace!.id); + getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); break; } if (workspaceInstance?.status.conditions.failed) { @@ -109,7 +108,6 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { }, [ workspaceInstance?.status.phase ]); return <> -
{workspaceInstance?.status.phase}
}> @@ -121,7 +119,7 @@ export default function PrebuildLogs(props: { workspaceId?: string }) { ; } -export function watchHeadlessLogs(server: GitpodServer, instanceId: string, onLog: (chunk: string) => void, checkIsDone: () => Promise): DisposableCollection { +export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => void, checkIsDone: () => Promise): DisposableCollection { const disposables = new DisposableCollection(); const startWatchingLogs = async () => { @@ -140,7 +138,7 @@ export function watchHeadlessLogs(server: GitpodServer, instanceId: string, onLo let response: Response | undefined = undefined; let reader: ReadableStreamDefaultReader | undefined = undefined; try { - const logSources = await server.getHeadlessLog(instanceId); + const logSources = await getGitpodService().server.getHeadlessLog(instanceId); // TODO(gpl) Only listening on first stream for now const streamIds = Object.keys(logSources.streams); if (streamIds.length < 1) { diff --git a/components/dashboard/src/icons/CaretUpDown.svg b/components/dashboard/src/icons/CaretUpDown.svg new file mode 100644 index 00000000000000..ececdc543c2a45 --- /dev/null +++ b/components/dashboard/src/icons/CaretUpDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/dashboard/src/projects/ConfigureProject.tsx b/components/dashboard/src/projects/ConfigureProject.tsx index d2f4a4c39f81a8..36cc8b44e84aaf 100644 --- a/components/dashboard/src/projects/ConfigureProject.tsx +++ b/components/dashboard/src/projects/ConfigureProject.tsx @@ -6,7 +6,7 @@ import React, { Suspense, useContext, useEffect, useState } from "react"; import { useLocation, useRouteMatch } from "react-router"; -import { CreateWorkspaceMode, ProjectInfo, WorkspaceCreationResult } from "@gitpod/gitpod-protocol"; +import { CreateWorkspaceMode, Project, WorkspaceCreationResult } from "@gitpod/gitpod-protocol"; import PrebuildLogs from "../components/PrebuildLogs"; import TabMenuItem from "../components/TabMenuItem"; import { getGitpodService } from "../service/service"; @@ -53,7 +53,7 @@ export default function () { const location = useLocation(); const team = getCurrentTeam(location, teams); const routeMatch = useRouteMatch<{ teamSlug: string, projectSlug: string }>("/:teamSlug/:projectSlug/configure"); - const [ project, setProject ] = useState(); + const [ project, setProject ] = useState(); const [ gitpodYml, setGitpodYml ] = useState(''); const [ dockerfile, setDockerfile ] = useState(''); const [ editorError, setEditorError ] = useState(null); diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx index a2469469080462..93b1cc168da6e6 100644 --- a/components/dashboard/src/projects/NewProject.tsx +++ b/components/dashboard/src/projects/NewProject.tsx @@ -219,11 +219,15 @@ export default function NewProject() {
{toSimpleName(r.name)}

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

-
-
- +
+
+ {!r.inUse ? ( + + ) : ( +

already taken

+ )} +
-
))}
diff --git a/components/dashboard/src/projects/Prebuild.tsx b/components/dashboard/src/projects/Prebuild.tsx new file mode 100644 index 00000000000000..6946dd5685bfb1 --- /dev/null +++ b/components/dashboard/src/projects/Prebuild.tsx @@ -0,0 +1,78 @@ +/** + * 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 moment from "moment"; +import { PrebuildInfo } from "@gitpod/gitpod-protocol"; +import { useContext, useEffect, useState } from "react"; +import { useLocation, useRouteMatch } from "react-router"; +import Header from "../components/Header"; +import { getGitpodService } from "../service/service"; +import { TeamsContext, getCurrentTeam } from "../teams/teams-context"; +import { prebuildStatusIcon, prebuildStatusLabel } from "./Prebuilds"; +import PrebuildLogs from "../components/PrebuildLogs"; +import { shortCommitMessage } from "./render-utils"; + +export default function () { + const { teams } = useContext(TeamsContext); + const location = useLocation(); + 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(); + + useEffect(() => { + if (!team || !projectName || !prebuildId) { + return; + } + (async () => { + const prebuilds = await getGitpodService().server.findPrebuilds({ + projectName, + teamId: team.id, + prebuildId + }); + setPrebuild(prebuilds[0]); + })(); + }, [team]); + + + const renderTitle = () => { + if (!prebuild) { + return "unknown prebuild"; + } + return (

{prebuild.branch} #{prebuild.branchPrebuildNumber}

); + }; + + const renderSubtitle = () => { + if (!prebuild) { + return ""; + } + const statusIcon = prebuildStatusIcon(prebuild.status); + const status = prebuildStatusLabel(prebuild.status); + const startedByAvatar = prebuild.startedByAvatar && {prebuild.startedBy}; + return (
+
+
{statusIcon}
+ {status} +
+

·

+
+

{startedByAvatar}Triggered {moment(prebuild.startedAt).fromNow()}

+
+

·

+
+

{shortCommitMessage(prebuild.changeTitle)}

+
+
) + }; + + return <> +
+
+ + +} \ No newline at end of file diff --git a/components/dashboard/src/projects/Prebuilds.tsx b/components/dashboard/src/projects/Prebuilds.tsx index 90f6446d6202ee..a7d10f6790f772 100644 --- a/components/dashboard/src/projects/Prebuilds.tsx +++ b/components/dashboard/src/projects/Prebuilds.tsx @@ -5,23 +5,31 @@ */ import moment from "moment"; -import { PrebuildInfo, ProjectInfo } from "@gitpod/gitpod-protocol"; +import { PrebuildInfo, PrebuiltWorkspaceState, Project } from "@gitpod/gitpod-protocol"; import { useContext, useEffect, useState } from "react"; -import { useLocation, useRouteMatch } from "react-router"; +import { useHistory, useLocation, useRouteMatch } from "react-router"; import Header from "../components/Header"; -import DropDown from "../components/DropDown"; -import { ItemsList, Item, ItemField, ItemFieldContextMenu, ItemFieldIcon } from "../components/ItemsList"; +import DropDown, { DropDownEntry } from "../components/DropDown"; +import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; import { getGitpodService } from "../service/service"; import { TeamsContext, getCurrentTeam } from "../teams/teams-context"; +import { ContextMenuEntry } from "../components/ContextMenu"; +import { shortCommitMessage } from "./render-utils"; export default function () { + const history = useHistory(); const { teams } = useContext(TeamsContext); const location = useLocation(); const match = useRouteMatch<{ team: string, resource: string }>("/:team/:resource"); const projectName = match?.params?.resource; const team = getCurrentTeam(location, teams); - const [project, setProject] = useState(); + // @ts-ignore + const [project, setProject] = useState(); + const [defaultBranch, setDefaultBranch] = useState(); + + const [searchFilter, setSearchFilter] = useState(); + const [statusFilter, setStatusFilter] = useState(); const [prebuilds, setPrebuilds] = useState([]); @@ -32,90 +40,183 @@ export default function () { (async () => { const projects = await getGitpodService().server.getProjects(team.id); - const project = projects.find(p => p.name === projectName); + const project = projectName && projects.find(p => p.name === projectName); if (project) { setProject(project); - // setPrebuilds(await getGitpodService().server.getPrebuilds(team.id, project.id)); - setPrebuilds([{ - id: "123", - branch: "feature-branch", - cloneUrl: "http://github.com/cool-test-org/foo", - startedAt: "2021-06-21T08:45:16.807Z", - startedBy: "AlexTugarev", - project: "lama", - status: "available", - teamId: "ACME" - }, { - id: "123", - branch: "feature-branch", - cloneUrl: "http://github.com/cool-test-org/foo", - startedAt: "2021-06-20T08:45:16.807Z", - startedBy: "AlexTugarev", - project: "lama", - status: "available", - teamId: "ACME" - }]) + + const prebuilds = await getGitpodService().server.findPrebuilds({ + projectName: project.name, + teamId: team.id + }); + setPrebuilds(prebuilds); + + const details = await getGitpodService().server.getProjectOverview(team.id, project.name); + if (details?.branches) { + setDefaultBranch(details.branches.find(b => b.isDefault)?.name); + } } })(); }, [team]); + const prebuildContextMenu = (p: PrebuildInfo) => { + const running = p.status === "building"; + const entries: ContextMenuEntry[] = []; + entries.push({ + title: "View Prebuild", + onClick: () => openPrebuild(p) + }); + entries.push({ + title: "Trigger Prebuild", + onClick: () => triggerPrebuild(p.branch), + separator: running + }); + if (running) { + entries.push({ + title: "Cancel Prebuild", + customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', + onClick: () => window.alert('cancellation not yet supported') + }) + } + return entries; + } + + const statusFilterEntries = () => { + const entries: DropDownEntry[] = []; + entries.push({ + title: 'All', + onClick: () => setStatusFilter(undefined) + }); + entries.push({ + title: 'READY', + onClick: () => setStatusFilter("available") + }); + return entries; + } + + const filter = (p: PrebuildInfo) => { + if (statusFilter && statusFilter !== p.status) { + return false; + } + if (searchFilter && `${p.changeTitle} ${p.branch}`.toLowerCase().includes(searchFilter.toLowerCase()) === false) { + return false; + } + return true; + } + + const filteredPrebuilds = prebuilds.filter(filter); + + const openPrebuild = (pb: PrebuildInfo) => { + history.push(`/${team?.slug}/${projectName}/${pb.id}`); + } + + const triggerPrebuild = (branchName: string) => { + if (project) { + getGitpodService().server.triggerPrebuild(project.id, branchName); + } + } + + const formatDate = (date: string | undefined) => { + return date ? moment(date).fromNow() : "–"; + } + + const formatHash = (longHash: string) => { + return longHash ? longHash.substring(0, 8) : "–"; + } + + return <> -
+
- { /* TODO */ }} /> + setSearchFilter(e.target.value)} />
- { /* TODO */ } - }]} /> +
+
- - - + - Context + Prebuild - Started + Commit - - Status + + Branch - {prebuilds.map(p => - -
-   -
-
+ {filteredPrebuilds.map((p: PrebuildInfo) => -
-
{p.branch}
-

{p.cloneUrl}

+
openPrebuild(p)}> +
+
{prebuildStatusIcon(p.status)}
+ {prebuildStatusLabel(p.status)} +
+

{p.startedByAvatar && {p.startedBy}}Triggered {formatDate(p.startedAt)}

- +
-
{moment(p.startedAt).fromNow()}
-

{p.startedBy}

+
{shortCommitMessage(p.changeTitle)}
+

{p.changeAuthorAvatar && {p.changeAuthor}}Authored {formatDate(p.changeDate)} · {formatHash(p.changeHash)}

- {p.status} +
+ {p.branch} + #{p.branchPrebuildNumber} +
+ +
)}
; +} + +export function prebuildStatusLabel(status: PrebuiltWorkspaceState) { + switch (status) { + case "aborted": + return (failed); + case "available": + return (ready); + case "building": + return (running); + case "queued": + return (pending); + default: + break; + } +} +export function prebuildStatusIcon(status: PrebuiltWorkspaceState) { + switch (status) { + case "aborted": + return ( + + ) + case "available": + return ( + + ); + case "building": + return ( + + ); + case "queued": + return ( + + ); + default: + break; + } } \ No newline at end of file diff --git a/components/dashboard/src/projects/Project.tsx b/components/dashboard/src/projects/Project.tsx index cd5337dfac76df..40892cf8aac155 100644 --- a/components/dashboard/src/projects/Project.tsx +++ b/components/dashboard/src/projects/Project.tsx @@ -5,43 +5,129 @@ */ import moment from "moment"; -import { PrebuildInfo, ProjectInfo } from "@gitpod/gitpod-protocol"; +import { PrebuildInfo, PrebuiltWorkspaceState, Project } from "@gitpod/gitpod-protocol"; import { useContext, useEffect, useState } from "react"; -import { useLocation, useRouteMatch } from "react-router"; +import { useHistory, useLocation, useRouteMatch } from "react-router"; import Header from "../components/Header"; -import DropDown from "../components/DropDown"; -import { ItemsList, Item, ItemField, ItemFieldContextMenu, ItemFieldIcon } from "../components/ItemsList"; -import { getGitpodService } from "../service/service"; +import DropDown, { DropDownEntry } from "../components/DropDown"; +import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; +import { getGitpodService, gitpodHostUrl } from "../service/service"; import { TeamsContext, getCurrentTeam } from "../teams/teams-context"; +import { prebuildStatusIcon, prebuildStatusLabel } from "./Prebuilds"; +import { ContextMenuEntry } from "../components/ContextMenu"; +import { toRemoteURL } from "./render-utils"; export default function () { + const history = useHistory(); const { teams } = useContext(TeamsContext); const location = useLocation(); const match = useRouteMatch<{ team: string, resource: string }>("/:team/:resource"); const projectName = match?.params?.resource; const team = getCurrentTeam(location, teams); - const [project, setProject] = useState(); + const [project, setProject] = useState(); - const [prebuilds, setPrebuilds] = useState([]); + const [branches, setBranches] = useState([]); + const [lastPrebuilds, setLastPrebuilds] = useState>(new Map()); + + const [searchFilter, setSearchFilter] = useState(); + const [statusFilter, setStatusFilter] = useState(); useEffect(() => { - if (!team) { + updateProject(); + }, [team]); + + const updateProject = async () => { + if (!team || !projectName) { return; } - (async () => { - const projects = await getGitpodService().server.getProjects(team.id); + const projects = await getGitpodService().server.getProjects(team.id); - const project = projects.find(p => p.name === projectName); - if (project) { - setProject(project); - setPrebuilds(await getGitpodService().server.getPrebuilds(team.id, project.id)); + const project = projectName && projects.find(p => p.name === projectName); + if (!project) { + return; + } + + setProject(project); + + const details = await getGitpodService().server.getProjectOverview(team.id, project.name); + if (details) { + // default branch on top of the rest + const branches = details.branches.sort((a, b) => (b.isDefault as any) - (a.isDefault as any)) || []; + setBranches(branches); + + for (const b of branches) { + const lastPrebuild = await getGitpodService().server.findPrebuilds({ + projectName, + teamId: team.id, + branch: b.name, + latest: true, + }); + if (lastPrebuild[0]) { + setLastPrebuilds(prev => new Map(prev).set(b.name, lastPrebuild[0])); + } } - })(); - }, [team]); + } + + } + + const branchContextMenu = (branch: Project.BranchDetails) => { + const entries: ContextMenuEntry[] = []; + entries.push({ + title: "New Workspace", + onClick: () => onNewWorkspace(branch) + }); + entries.push({ + title: "Trigger Prebuild", + onClick: () => triggerPrebuild(branch), + separator: true + }); + entries.push({ + title: "Cancel Prebuild", + customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', + onClick: () => { } + }) + return entries; + } + + const statusFilterEntries = () => { + const entries: DropDownEntry[] = []; + entries.push({ + title: 'All', + onClick: () => setStatusFilter(undefined) + }); + entries.push({ + title: 'READY', + onClick: () => setStatusFilter("available") + }); + return entries; + } + + const lastPrebuild = (branch: Project.BranchDetails) => lastPrebuilds.get(branch.name); - const toRemoteURL = (cloneURL: string) => { - return cloneURL.replace("https://", ""); + const filter = (branch: Project.BranchDetails) => { + const prebuild = lastPrebuild(branch); + if (statusFilter && prebuild && statusFilter !== prebuild.status) { + return false; + } + if (searchFilter && `${branch.changeTitle} ${branch.name}`.toLowerCase().includes(searchFilter.toLowerCase()) === false) { + return false; + } + return true; + } + + const onNewWorkspace = (branch: Project.BranchDetails) => { + window.location.href = gitpodHostUrl.withContext(`${branch.url}`).toString(); + } + + const triggerPrebuild = (branch: Project.BranchDetails) => { + if (project) { + getGitpodService().server.triggerPrebuild(project.id, branch.name) + } + } + + const openPrebuild = (pb: PrebuildInfo) => { + history.push(`/${team?.slug}/${projectName}/${pb.id}`); } return <> @@ -52,53 +138,67 @@ export default function () {
- { /* TODO */ }} /> + setSearchFilter(e.target.value)} />
- { /* TODO */ } - }]} /> +
- - - + - Context + Branch - Started + Commit - - Status + + Prebuild - {prebuilds.map(p => - -
-   -
-
- -
-
{p.branch}
-

{p.cloneUrl}

-
-
- -
-
{moment(p.startedAt).fromNow()}
-

{p.startedBy}

-
-
- - {p.status} - -
)} + {branches.map((branch, index) => { + + const branchName = branch.name; + const prebuild = lastPrebuild(branch); + + const avatar = branch.changeAuthorAvatar && {branch.changeAuthor}; + const fakeShortHash = branch.changeHash.substring(0, 10); + const statusIcon = prebuild?.status && prebuildStatusIcon(prebuild.status); + const status = prebuild?.status && prebuildStatusLabel(prebuild.status); + console.log(`status for ${branchName} is ${prebuild?.status} (${lastPrebuilds.size})`) + if (!filter(branch)) { + // return undefined; + } + return + +
+
+ {branchName} +
+

Updated _ minutes ago

+
+
+ +
+
{branch.changeTitle}
+

{avatar}Authored {moment(branch.changeDate).fromNow()} · {fakeShortHash}

+
+
+ +
prebuild && openPrebuild(prebuild)}> + {prebuild ? (<>
{statusIcon}
{status}) : ()} +
+ + + + + +
+
+ } + )}
diff --git a/components/dashboard/src/projects/Projects.tsx b/components/dashboard/src/projects/Projects.tsx index 14c5fe36b80356..1406019a30f1cb 100644 --- a/components/dashboard/src/projects/Projects.tsx +++ b/components/dashboard/src/projects/Projects.tsx @@ -12,8 +12,10 @@ import { useHistory, useLocation } from "react-router"; import { useContext, useEffect, useState } from "react"; import { getGitpodService } from "../service/service"; import { getCurrentTeam, TeamsContext } from "../teams/teams-context"; -import { ProjectInfo } from "@gitpod/gitpod-protocol"; +import { PrebuildInfo, Project } from "@gitpod/gitpod-protocol"; import DropDown from "../components/DropDown"; +import { toRemoteURL } from "./render-utils"; +import ContextMenu from "../components/ContextMenu"; export default function () { const location = useLocation(); @@ -21,29 +23,44 @@ export default function () { const { teams } = useContext(TeamsContext); const team = getCurrentTeam(location, teams); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); + const [lastPrebuilds, setLastPrebuilds] = useState>(new Map()); useEffect(() => { + updateProjects(); + }, [team]); + + const updateProjects = async () => { if (!team) { return; } - (async () => { - const infos = await getGitpodService().server.getProjects(team.id); - setProjects(infos); - })(); - }, [team]); + const infos = await getGitpodService().server.getProjects(team.id); + setProjects(infos); + + for (const p of infos) { + const lastPrebuild = await getGitpodService().server.findPrebuilds({ + projectName: p.name, + teamId: team.id, + latest: true, + }); + if (lastPrebuild[0]) { + setLastPrebuilds(prev => new Map(prev).set(p.id, lastPrebuild[0])); + } + } + } const onSearchProjects = (searchString: string) => { } const onNewProject = () => { history.push(`/new?team=${team?.slug}`); } - const viewAllPrebuilds = (p: ProjectInfo) => { + const viewAllPrebuilds = (p: Project) => { history.push(`/${team?.slug}/${p.name}/prebuilds`); } - const toRemoteURL = (cloneURL: string) => { - return cloneURL.replace("https://", ""); + const onRemoveProject = async (p: Project) => { + await getGitpodService().server.deleteProject(p.id); + await updateProjects(); } return <> @@ -84,21 +101,32 @@ export default function () {
{projects.map(p => (
-
-
{p.name}
+
+
+ + {p.name} + + +
+ onRemoveProject(p) + }]} /> +
+

{toRemoteURL(p.cloneUrl)}

__ Active Branches

-
viewAllPrebuilds(p)}> - {p.lastPrebuild +
viewAllPrebuilds(p)}> + {lastPrebuilds.get(p.id) ? (
 
-
{p.lastPrebuild.branch}
-
{moment(p.lastPrebuild.startedAt, "YYYYMMDD").fromNow()}
+
{lastPrebuilds.get(p.id)!.branch}
+
{moment(lastPrebuilds.get(p.id)!.startedAt, "YYYYMMDD").fromNow()}
View All ⟶
) : (
diff --git a/components/dashboard/src/projects/render-utils.tsx b/components/dashboard/src/projects/render-utils.tsx new file mode 100644 index 00000000000000..b1768339031fca --- /dev/null +++ b/components/dashboard/src/projects/render-utils.tsx @@ -0,0 +1,15 @@ +/** + * 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. + */ + + +export function toRemoteURL(cloneURL: string) { + return cloneURL.replace("https://", "").replace(".git", ""); +} + +export function shortCommitMessage(message: string) { + const firstLine = message.split("/n")[0]; + return firstLine.length > 50 ? firstLine.substring(0, 45) + " …" : firstLine; +} diff --git a/components/dashboard/src/service/service-mock.ts b/components/dashboard/src/service/service-mock.ts index 998c5a9aaa689d..30c5a27186a09e 100644 --- a/components/dashboard/src/service/service-mock.ts +++ b/components/dashboard/src/service/service-mock.ts @@ -4,29 +4,151 @@ * See License-AGPL.txt in the project root for license information. */ -import { createServiceMock, Event } from "@gitpod/gitpod-protocol"; +import { createServiceMock, Event, Project, Team, User } from "@gitpod/gitpod-protocol"; +const u1: User = { + "id": "1234", + "creationDate": "2018-05-01T07:00:00.000Z", + "avatarUrl": "https://avatars.githubusercontent.com/u/10137?v=4", + "name": "gp-test", + "fullName": "Alex", + "allowsMarketingCommunication": true, + "identities": [ + { + "authProviderId": "Public-GitHub", + "authId": "1234", + "authName": "GitpodTester", + "primaryEmail": "tester@gitpod.io", + } + ], + rolesOrPermissions: ["teams-and-projects"], + additionalData: { + whatsNewSeen: { + "April-2021": "true", + "June-2021": "true", + } + } +} +const t1 = new Date(Date.now() - 123533).toISOString(); +const team1: Team = { + id: "team1", + name: "ACME", + slug: "ACME", + creationTime: t1 +} +const pr1: Project = { + appInstallationId: "app1", + cloneUrl: "https://github.com/AlexTugarev/txt.git", + creationTime: t1, + id: "pr1", + name: "TXT", + teamId: "team1", +} const gitpodServiceMock = createServiceMock({ getLoggedInUser: async () => { + return u1; + }, + updateLoggedInUser: async (user: User) => { + for (const attribute in user) { + // @ts-ignore + u1[attribute] = user[attribute]; + } + return u1; + }, + getTeams: async () => { + return [team1] + }, + getTeamMembers: async (teamId) => { + return [{ + memberSince: t1, + role: "owner", + userId: u1.id, + avatarUrl: u1.avatarUrl, + fullName: u1.fullName, + primaryEmail: "alex@gitpod.io", + }] + }, + getGenericInvite: async () => { return { - "id": "1234", - "creationDate": "2018-05-01T07:00:00.000Z", - "avatarUrl": "https://avatars.githubusercontent.com/u/37021919?s=60&v=4", - "name": "gp-test", - "fullName": "Gitpod Tester", - "allowsMarketingCommunication": true, - "identities": [ - { - "authProviderId": "Public-GitHub", - "authId": "1234", - "authName": "GitpodTester", - "primaryEmail": "tester@gitpod.io", - } - ] + id: "000", + creationTime: t1, + invalidationTime: t1, + role: "member", + teamId: "team1", } }, + getProjects: async () => { + return [pr1] + }, + getProjectOverview: async () => { + return { + branches: [{ + name: "main", + url: "branchUrl", + changeDate: t1, + changeAuthor: u1.fullName!, + changeAuthorAvatar: u1.avatarUrl, + changeHash: "2C0FFE", + changeTitle: "[Comp] Add new functionality for", + isDefault: true, + status: "available", + }] + } + }, + findPrebuilds: async (p) => { + const { projectName, teamId } = p; + return [{ + id: "pb1", + branch: "main", + buildWorkspaceId: "123", + branchPrebuildNumber: "123342", + projectName, + teamId, + cloneUrl: pr1.cloneUrl, + startedAt: t1, + startedBy: u1.id, + startedByAvatar: u1.avatarUrl, + status: "available", + changeTitle: "[Comp] Add new functionality for", + changeDate: t1, + changeAuthor: u1.fullName!, + changeAuthorAvatar: u1.avatarUrl, + changePR: "4647", + changeUrl: "https://github.com/gitpod-io/gitpod/pull/4738", + changeHash: "2C0FFE" + }, { + id: "pb1", + branch: "foo/bar", + buildWorkspaceId: "1234", + branchPrebuildNumber: "3", + projectName, + teamId, + cloneUrl: pr1.cloneUrl, + startedAt: t1, + startedBy: u1.id, + startedByAvatar: u1.avatarUrl, + status: "aborted", + changeTitle: "Fix Bug Nr 1", + changeDate: t1, + changeAuthor: u1.fullName!, + changeAuthorAvatar: u1.avatarUrl, + changePR: "4245", + changeUrl: "https://github.com/gitpod-io/gitpod/pull/4738", + changeHash: "1C0FFE" + } + ] + }, + getProviderRepositoriesForUser: async () => { + return [] + }, + getWorkspaces: async () => { + return [] + }, + getFeaturedRepositories: async () => { + return [] + }, getAuthProviders: async () => { return [{ "authProviderId": "Public-GitHub", @@ -55,15 +177,15 @@ const gitpodServiceMock = createServiceMock({ "host": "testing.doptig.com/gitlab", "type": "GitLab", "oauth": { - "authorizationUrl": "https://testing.doptig.com/gitlab/oauth/authorize", - "tokenUrl": "https://testing.doptig.com/gitlab/oauth/token", - "settingsUrl": "https://testing.doptig.com/gitlab/-/profile/applications", - "callBackUrl": "https://gitpod-staging.com/auth/testing.doptig.com/gitlab/callback", - "clientId": "clientid-123", - "clientSecret": "redacted" + "authorizationUrl": "https://testing.doptig.com/gitlab/oauth/authorize", + "tokenUrl": "https://testing.doptig.com/gitlab/oauth/token", + "settingsUrl": "https://testing.doptig.com/gitlab/-/profile/applications", + "callBackUrl": "https://gitpod-staging.com/auth/testing.doptig.com/gitlab/callback", + "clientId": "clientid-123", + "clientSecret": "redacted" }, "deleted": false - }] + }] }, onDidOpenConnection: Event.None, onDidCloseConnection: Event.None, diff --git a/components/dashboard/src/service/service.tsx b/components/dashboard/src/service/service.tsx index bf33a1dbb90d94..1fc1584b913456 100644 --- a/components/dashboard/src/service/service.tsx +++ b/components/dashboard/src/service/service.tsx @@ -10,9 +10,11 @@ import { createWindowMessageConnection } from '@gitpod/gitpod-protocol/lib/messa import { JsonRpcProxyFactory } from '@gitpod/gitpod-protocol/lib/messaging/proxy-factory'; import { GitpodHostUrl } from '@gitpod/gitpod-protocol/lib/util/gitpod-host-url'; import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; +import { gitpodServiceMock } from './service-mock'; export const gitpodHostUrl = new GitpodHostUrl(window.location.toString()); +// @ts-ignore function createGitpodService() { if (window.top !== window.self && process.env.NODE_ENV === 'production') { const connection = createWindowMessageConnection('gitpodServer', window.parent, '*'); @@ -51,7 +53,14 @@ function createGitpodService() { function getGitpodService(): GitpodService { const w = window as any; const _gp = w._gp || (w._gp = {}); - const service = _gp.gitpodService || (_gp.gitpodService = createGitpodService()); + const createService = () => { + if (window.location.search.includes("mock")) { + return gitpodServiceMock; + } else { + return createGitpodService(); + } + } + const service = _gp.gitpodService || (_gp.gitpodService = createService()); return service; } diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index e05c83c04735bf..9928813fe81764 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -247,7 +247,6 @@ function RepositoryNotFoundView(p: { error: StartWorkspaceError }) { const [statusMessage, setStatusMessage] = useState(); useEffect(() => { (async () => { - const service = getGitpodService(); const { host, owner, repoName, userIsOwner, userScopes, lastUpdate } = p.error.data; console.log('host', host); console.log('owner', owner); @@ -256,12 +255,12 @@ function RepositoryNotFoundView(p: { error: StartWorkspaceError }) { console.log('userScopes', userScopes); console.log('lastUpdate', lastUpdate); - if ((await service.server.mayAccessPrivateRepo()) === false) { + if ((await getGitpodService().server.mayAccessPrivateRepo()) === false) { setStatusMessage(); return; } - const authProvider = (await service.server.getAuthProviders()).find(p => p.host === host); + const authProvider = (await getGitpodService().server.getAuthProviders()).find(p => p.host === host); if (!authProvider) { return; } @@ -334,7 +333,6 @@ interface RunningPrebuildViewProps { function RunningPrebuildView(props: RunningPrebuildViewProps) { const logsEmitter = new EventEmitter(); - const service = getGitpodService(); let pollTimeout: NodeJS.Timeout | undefined; let prebuildDoneTriggered: boolean = false; @@ -345,7 +343,7 @@ function RunningPrebuildView(props: RunningPrebuildViewProps) { return true; } - const done = await service.server.isPrebuildDone(props.runningPrebuild.prebuildID); + const done = await getGitpodService().server.isPrebuildDone(props.runningPrebuild.prebuildID); if (done) { // note: this treats "done" as "available" which is not equivalent. // This works because the backend ignores prebuilds which are not "available", and happily starts a workspace as if there was no prebuild at all. @@ -361,7 +359,7 @@ function RunningPrebuildView(props: RunningPrebuildViewProps) { pollTimeout = setTimeout(pollIsPrebuildDone, 10000); }; - const disposables = watchHeadlessLogs(service.server, props.runningPrebuild.instanceID, (chunk) => logsEmitter.emit('logs', chunk), checkIsPrebuildDone); + const disposables = watchHeadlessLogs(props.runningPrebuild.instanceID, (chunk) => logsEmitter.emit('logs', chunk), checkIsPrebuildDone); return function cleanup() { clearTimeout(pollTimeout!); disposables.dispose(); diff --git a/components/dashboard/src/start/StartWorkspace.tsx b/components/dashboard/src/start/StartWorkspace.tsx index f5e47bca0e5e13..08b9781a64ff80 100644 --- a/components/dashboard/src/start/StartWorkspace.tsx +++ b/components/dashboard/src/start/StartWorkspace.tsx @@ -314,11 +314,10 @@ function ImageBuildView(props: ImageBuildViewProps) { const logsEmitter = new EventEmitter(); useEffect(() => { - const service = getGitpodService(); - const watchBuild = () => service.server.watchWorkspaceImageBuildLogs(props.workspaceId); + const watchBuild = () => getGitpodService().server.watchWorkspaceImageBuildLogs(props.workspaceId); watchBuild(); - const toDispose = service.registerClient({ + const toDispose = getGitpodService().registerClient({ notifyDidOpenConnection: () => watchBuild(), onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => { if (!content) { @@ -345,8 +344,7 @@ function HeadlessWorkspaceView(props: { instanceId: string }) { const logsEmitter = new EventEmitter(); useEffect(() => { - const service = getGitpodService(); - const disposables = watchHeadlessLogs(service.server, props.instanceId, (chunk) => logsEmitter.emit('logs', chunk), async () => { return false; }); + const disposables = watchHeadlessLogs(props.instanceId, (chunk) => logsEmitter.emit('logs', chunk), async () => { return false; }); return function cleanup() { disposables.dispose(); }; diff --git a/components/gitpod-db/src/project-db.ts b/components/gitpod-db/src/project-db.ts index 4fbf9df8b2d15c..35a4fbb60913ef 100644 --- a/components/gitpod-db/src/project-db.ts +++ b/components/gitpod-db/src/project-db.ts @@ -10,8 +10,11 @@ export const ProjectDB = Symbol('ProjectDB'); export interface ProjectDB { findProjectById(projectId: string): Promise; findProjectByCloneUrl(cloneUrl: string): Promise; + findProjectsByCloneUrl(cloneUrls: string[]): Promise; + findProjectByTeamAndName(teamId: string, projectName: string): Promise; findProjectByInstallationId(installationId: string): Promise; findProjectsByTeam(teamId: string): Promise; storeProject(project: Project): Promise; setProjectConfiguration(projectId: string, config: ProjectConfig): Promise; -} \ No newline at end of file + markDeleted(projectId: string): Promise; +} diff --git a/components/gitpod-db/src/typeorm/entity/db-project.ts b/components/gitpod-db/src/typeorm/entity/db-project.ts index 101f5d68d67b05..3f1368a038062b 100644 --- a/components/gitpod-db/src/typeorm/entity/db-project.ts +++ b/components/gitpod-db/src/typeorm/entity/db-project.ts @@ -36,4 +36,7 @@ export class DBProject { // This column triggers the db-sync deletion mechanism. It's not intended for public consumption. @Column() deleted: boolean; + + @Column() + markedDeleted: boolean; } \ No newline at end of file diff --git a/components/gitpod-db/src/typeorm/migration/1626881279438-AddMarkedDeletedToProject.ts b/components/gitpod-db/src/typeorm/migration/1626881279438-AddMarkedDeletedToProject.ts new file mode 100644 index 00000000000000..ea8c69b4531942 --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1626881279438-AddMarkedDeletedToProject.ts @@ -0,0 +1,21 @@ +/** + * 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 {MigrationInterface, QueryRunner} from "typeorm"; +import { columnExists } from "./helper/helper"; + +export class AddMarkedDeletedToProject1626881279438 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + if (!(await columnExists(queryRunner, "d_b_project", "markedDeleted"))) { + await queryRunner.query("ALTER TABLE d_b_project ADD COLUMN `markedDeleted` tinyint(4) NOT NULL DEFAULT '0'"); + } + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/components/gitpod-db/src/typeorm/project-db-impl.ts b/components/gitpod-db/src/typeorm/project-db-impl.ts index 9581b004a5526d..029fed4aaf2305 100644 --- a/components/gitpod-db/src/typeorm/project-db-impl.ts +++ b/components/gitpod-db/src/typeorm/project-db-impl.ts @@ -25,22 +25,39 @@ export class ProjectDBImpl implements ProjectDB { public async findProjectById(projectId: string): Promise { const repo = await this.getRepo(); - return repo.findOne({ id: projectId }); + return repo.findOne({ id: projectId, markedDeleted: false }); } public async findProjectByCloneUrl(cloneUrl: string): Promise { const repo = await this.getRepo(); - return repo.findOne({ cloneUrl }); + return repo.findOne({ cloneUrl, markedDeleted: false }); + } + + public async findProjectsByCloneUrl(cloneUrls: string[]): Promise { + if (cloneUrls.length === 0) { + return []; + } + const repo = await this.getRepo(); + const q = repo.createQueryBuilder("project") + .where("project.markedDeleted = false") + .andWhere(`project.cloneUrl in (${ cloneUrls.map(u => `'${u}'`).join(", ") })`) + const result = await q.getMany(); + return result; + } + + public async findProjectByTeamAndName(teamId: string, projectName: string): Promise { + const projects = await this.findProjectsByTeam(teamId); + return projects.find(p => p.name === projectName); } public async findProjectByInstallationId(appInstallationId: string): Promise { const repo = await this.getRepo(); - return repo.findOne({ appInstallationId }); + return repo.findOne({ appInstallationId, markedDeleted: false }); } public async findProjectsByTeam(teamId: string): Promise { const repo = await this.getRepo(); - return repo.find({ teamId }); + return repo.find({ teamId, markedDeleted: false }); } public async storeProject(project: Project): Promise { @@ -57,4 +74,13 @@ export class ProjectDBImpl implements ProjectDB { project.config = config; await repo.save(project); } + + public async markDeleted(projectId: string): Promise { + const repo = await this.getRepo(); + const project = await repo.findOne({ id: projectId }); + if (project) { + project.markedDeleted = true; + await repo.save(project); + } + } } diff --git a/components/gitpod-db/src/typeorm/workspace-db-impl.ts b/components/gitpod-db/src/typeorm/workspace-db-impl.ts index 028daafb76006c..3a53525e491ae4 100644 --- a/components/gitpod-db/src/typeorm/workspace-db-impl.ts +++ b/components/gitpod-db/src/typeorm/workspace-db-impl.ts @@ -134,7 +134,7 @@ export abstract class AbstractTypeORMWorkspaceDBImpl implements WorkspaceDB { * thus GREATEST gives us the highest (newest) timestamp on the running instance which correlates to the last activity on that workspace */ const repo = await this.getWorkspaceRepo(); - let qb = repo + const qb = repo .createQueryBuilder('ws') // We need to put the subquery into the join condition (ON) here to be able to reference `ws.id` which is // not possible in a subquery on JOIN (e.g. 'LEFT JOIN (SELECT ... WHERE i.workspaceId = ws.id)') @@ -152,20 +152,23 @@ export abstract class AbstractTypeORMWorkspaceDBImpl implements WorkspaceDB { .from(DBWorkspaceInstance, 'i2') .where('i2.phasePersisted = "running"'); }, 'wsiRunning', 'ws.id = wsiRunning.workspaceId') - .where('ws.ownerId = :userId', options) + .where('ws.ownerId = :userId', { userId: options.userId }) .andWhere('ws.softDeleted IS NULL') .andWhere('ws.deleted != TRUE') .orderBy('wsiRunning.workspaceId', 'DESC') .addOrderBy('GREATEST(ws.creationTime, wsi.creationTime, wsi.startedTime, wsi.stoppedTime)', 'DESC') .limit(options.limit || 10); if (options.searchString) { - qb = qb.andWhere("ws.description LIKE :searchString", {searchString: `%${options.searchString}%`}); + qb.andWhere("ws.description LIKE :searchString", {searchString: `%${options.searchString}%`}); } if (!options.includeHeadless) { - qb = qb.andWhere("ws.type = 'regular'"); + qb.andWhere("ws.type = 'regular'"); } if (options.pinnedOnly) { - qb = qb.andWhere("ws.pinned = true"); + qb.andWhere("ws.pinned = true"); + } + if (options.projectId) { + qb.andWhere('ws.projectId = :projectId', { projectId: options.projectId }) } const rawResults = await qb.getMany() as any as (Workspace & { latestInstance?: WorkspaceInstance })[]; // see leftJoinAndMapOne above @@ -819,18 +822,36 @@ export abstract class AbstractTypeORMWorkspaceDBImpl implements WorkspaceDB { return (res); } - async findPrebuiltWorkspacesByProject(projectId: string): Promise { + async findPrebuiltWorkspacesByProject(projectId: string, branch?: string, limit?: number): Promise { const repo = await this.getPrebuiltWorkspaceRepo(); const query = repo.createQueryBuilder('pws') .orderBy('pws.creationTime', 'ASC') .innerJoinAndMapOne('pws.workspace', DBWorkspace, 'ws', 'pws.buildWorkspaceId = ws.id') - .andWhere('pws.projectId = :projectId', { projectId }) + .andWhere('pws.projectId = :projectId', { projectId }); + + if (branch) { + query.andWhere('pws.branch = :branch', { branch }); + } + if (limit) { + query.limit(limit); + } const res = await query.getMany(); return res; } + async findPrebuiltWorkspacesById(id: string): Promise { + const repo = await this.getPrebuiltWorkspaceRepo(); + + const query = repo.createQueryBuilder('pws') + .orderBy('pws.creationTime', 'ASC') + .innerJoinAndMapOne('pws.workspace', DBWorkspace, 'ws', 'pws.buildWorkspaceId = ws.id') + .andWhere('pws.id = :id', { id }); + + return query.getOne(); + } + } @injectable() diff --git a/components/gitpod-db/src/workspace-db.ts b/components/gitpod-db/src/workspace-db.ts index eac79674ac24a8..2026b104e47168 100644 --- a/components/gitpod-db/src/workspace-db.ts +++ b/components/gitpod-db/src/workspace-db.ts @@ -13,6 +13,7 @@ export type MaybeWorkspaceInstance = WorkspaceInstance | undefined; export interface FindWorkspacesOptions { userId: string + projectId?: string limit?: number searchString?: string includeHeadless?: boolean @@ -112,5 +113,6 @@ export interface WorkspaceDB { hardDeleteWorkspace(workspaceID: string): Promise; - findPrebuiltWorkspacesByProject(projectId: string): Promise; + findPrebuiltWorkspacesByProject(projectId: string, branch?: string, limit?: number): Promise; + findPrebuiltWorkspacesById(prebuildId: string): Promise; } diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 68bbd85ff5eb91..7177cfd5c16925 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -13,7 +13,7 @@ import { } from './protocol'; import { Team, TeamMemberInfo, - TeamMembershipInvite, Project, ProjectInfo, PrebuildInfo, TeamMemberRole + TeamMembershipInvite, Project, PrebuildInfo, TeamMemberRole } from './teams-projects-protocol'; import { JsonRpcProxy, JsonRpcServer } from './messaging/proxy-factory'; import { Disposable, CancellationTokenSource } from 'vscode-jsonrpc'; @@ -126,8 +126,11 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, // Projects getProviderRepositoriesForUser(params: GetProviderRepositoriesParams): Promise; createProject(params: CreateProjectParams): Promise; - getProjects(teamId: string): Promise; - getPrebuilds(teamId: string, projectId: string): Promise; + deleteProject(projectId: string): Promise; + getProjects(teamId: string): Promise; + getProjectOverview(teamId: string, projectName: string): Promise; + findPrebuilds(params: FindPrebuildsParams): Promise; + triggerPrebuild(projectId: string, branch: string): Promise; setProjectConfiguration(projectId: string, configString: string): Promise; fetchProjectRepositoryConfiguration(projectId: string): Promise; @@ -231,6 +234,13 @@ export interface CreateProjectParams { teamId: string; appInstallationId: string; } +export interface FindPrebuildsParams { + teamId: string; + projectName: string; + branch?: string; + latest?: boolean; + prebuildId?: string; +} export interface GetProviderRepositoriesParams { provider: string; hints?: { installationId: string } | object; @@ -243,6 +253,8 @@ export interface ProviderRepository { updatedAt: string; installationId?: number; installationUpdatedAt?: string; + + inUse?: boolean; } export const WorkspaceTimeoutValues = ["30m", "60m", "180m"] as const; @@ -288,6 +300,7 @@ export namespace GitpodServer { limit?: number; searchString?: string; pinnedOnly?: boolean; + projectId?: string; } export interface GetAccountStatementOptions { date?: string; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 66afe2cb7c3db0..2fa537a4b2bc7c 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -1008,6 +1008,18 @@ export interface Repository { parent: Repository } } +export interface Branch { + name: string; + commit: CommitInfo; +} + +export interface CommitInfo { + author: string; + sha: string; + commitMessage: string; + authorAvatarUrl?: string; + authorDate?: string; +} export namespace Repository { export function fullRepoName(repo: Repository): string { diff --git a/components/gitpod-protocol/src/teams-projects-protocol.ts b/components/gitpod-protocol/src/teams-projects-protocol.ts index 37b2f9e84867ac..f149aef348db5e 100644 --- a/components/gitpod-protocol/src/teams-projects-protocol.ts +++ b/components/gitpod-protocol/src/teams-projects-protocol.ts @@ -21,6 +21,7 @@ export interface Project { creationTime: string; /** This is a flag that triggers the HARD DELETION of this entity */ deleted?: boolean; + markedDeleted?: boolean; } export namespace Project { @@ -31,21 +32,47 @@ export namespace Project { creationTime: new Date().toISOString() }; } -} -export interface ProjectInfo extends Project { - lastPrebuild?: PrebuildInfo; + export interface Overview { + branches: BranchDetails[] + } + + export interface BranchDetails { + name: string; + url: string; + isDefault: boolean; + + // Latest commit + changeTitle: string; + changeDate?: string; + changeAuthor?: string; + changeAuthorAvatar?: string; + changePR?: string; + changeUrl?: string; + changeHash: string; + } } export interface PrebuildInfo { id: string; teamId: string; - project: string; + projectName: string; cloneUrl: string; branch: string; + branchPrebuildNumber: string; + buildWorkspaceId: string; + startedAt: string; startedBy: string; + startedByAvatar?: string; status: PrebuiltWorkspaceState; + changeTitle: string; + changeDate: string; + changeAuthor: string; + changeAuthorAvatar?: string; + changePR?: string; + changeUrl?: string; + changeHash: string; } export interface Team { diff --git a/components/licensor/typescript/ee/src/index.ts b/components/licensor/typescript/ee/src/index.ts index f547939a3bca26..8c39805045c6b4 100644 --- a/components/licensor/typescript/ee/src/index.ts +++ b/components/licensor/typescript/ee/src/index.ts @@ -5,7 +5,7 @@ */ import { injectable, inject, postConstruct } from 'inversify'; -import { init, Instance, dispose, isEnabled, hasEnoughSeats, canUsePrebuild, inspect, validate } from "./nativemodule"; +import { init, Instance, dispose, inspect, validate } from "./nativemodule"; import { Feature, LicensePayload } from './api'; export const LicenseKeySource = Symbol("LicenseKeySource"); @@ -38,27 +38,30 @@ export class LicenseEvaluator { public async reloadLicense() { this.dispose() - await this.init() + // await this.init() } public validate(): { msg?: string, valid: boolean } { - const v = validate(this.instanceID); - if (v.valid) { + // const v = validate(this.instanceID); + // if (v.valid) { return { valid: true }; - } - return { msg: v.msg, valid: false }; + // } + // return { msg: v.msg, valid: false }; } public isEnabled(feature: Feature): boolean { - return isEnabled(this.instanceID, feature); + // return isEnabled(this.instanceID, feature); + return true; } public hasEnoughSeats(seats: number): boolean { - return hasEnoughSeats(this.instanceID, seats); + // return hasEnoughSeats(this.instanceID, seats); + return true; } public canUsePrebuild(totalPrebuildSecondsSpent: number): boolean { - return canUsePrebuild(this.instanceID, totalPrebuildSecondsSpent); + // return canUsePrebuild(this.instanceID, totalPrebuildSecondsSpent); + return true; } public inspect(): LicensePayload { diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index bdcd165c832ab6..7b13d00ea9be23 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -7,7 +7,7 @@ import { injectable, inject } from "inversify"; import { GitpodServerImpl } from "../../../src/workspace/gitpod-server-impl"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; -import { GitpodServer, GitpodClient, AdminGetListRequest, User, AdminGetListResult, Permission, AdminBlockUserRequest, AdminModifyRoleOrPermissionRequest, RoleOrPermission, AdminModifyPermanentWorkspaceFeatureFlagRequest, UserFeatureSettings, AdminGetWorkspacesRequest, WorkspaceAndInstance, GetWorkspaceTimeoutResult, WorkspaceTimeoutDuration, WorkspaceTimeoutValues, SetWorkspaceTimeoutResult, WorkspaceContext, CreateWorkspaceMode, WorkspaceCreationResult, PrebuiltWorkspaceContext, CommitContext, PrebuiltWorkspace, PermissionName, WorkspaceInstance, EduEmailDomain, ProviderRepository, PrebuildInfo, Queue } from "@gitpod/gitpod-protocol"; +import { GitpodServer, GitpodClient, AdminGetListRequest, User, AdminGetListResult, Permission, AdminBlockUserRequest, AdminModifyRoleOrPermissionRequest, RoleOrPermission, AdminModifyPermanentWorkspaceFeatureFlagRequest, UserFeatureSettings, AdminGetWorkspacesRequest, WorkspaceAndInstance, GetWorkspaceTimeoutResult, WorkspaceTimeoutDuration, WorkspaceTimeoutValues, SetWorkspaceTimeoutResult, WorkspaceContext, CreateWorkspaceMode, WorkspaceCreationResult, PrebuiltWorkspaceContext, CommitContext, PrebuiltWorkspace, PermissionName, WorkspaceInstance, EduEmailDomain, ProviderRepository, Queue } from "@gitpod/gitpod-protocol"; import { ResponseError } from "vscode-jsonrpc"; import { TakeSnapshotRequest, AdmissionLevel, ControlAdmissionRequest, StopWorkspacePolicy, DescribeWorkspaceRequest, SetTimeoutRequest } from "@gitpod/ws-manager/lib"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; @@ -1451,34 +1451,39 @@ export class GitpodServerEEImpl extends GitpodServerImpl r.cloneUrl); + const projects = await this.projectDB.findProjectsByCloneUrl(cloneUrls); + + const cloneUrlsInUse = new Set(projects.map(p => p.cloneUrl)); + repositories.forEach(r => { r.inUse = cloneUrlsInUse.has(r.cloneUrl) }); + return repositories; } - public async getPrebuilds(teamId: string, projectName: string): Promise { - this.checkAndBlockUser("getPrebuilds"); - const span = opentracing.globalTracer().startSpan("getPrebuilds"); - span.setTag("teamId", teamId); - span.setTag("projectName", projectName); - const result: PrebuildInfo[] = []; - - const project = (await this.projectDB.findProjectsByTeam(teamId)).find(p => p.name === projectName); - if (project) { - const pwss = await this.workspaceDb.trace({ span }).findPrebuiltWorkspacesByProject(project.id); + async triggerPrebuild(projectId: string, branch: string): Promise { + const user = this.checkAndBlockUser("triggerPrebuild"); - for (const pws of pwss) { - result.push({ - id: pws.id, - startedAt: pws.creationTime, - startedBy: "UNKNOWN", - teamId, - project: projectName, - branch: pws.branch || "unknown", - cloneUrl: pws.cloneURL, - status: pws.state - }); - } + const project = await this.projectsService.getProject(projectId); + if (!project) { + return; } + await this.guardTeamOperation(project.teamId, "get"); - return result; + const span = opentracing.globalTracer().startSpan("triggerPrebuild"); + span.setTag("userId", user.id); + + const contextURL = `${project.cloneUrl.replace(".git", "")}/tree/${branch}`; // just a quick hack! + + const context = await this.contextParser.handle({ span }, user, contextURL) as CommitContext; + + await this.prebuildManager.startPrebuild({ span }, { + contextURL, + cloneURL: project.cloneUrl, + commit: context.revision, + user, + branch, + project + }); } } diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index bbaa1c20b68201..394bc6634233f5 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -91,7 +91,10 @@ function readConfig(): RateLimiterConfig { "getProviderRepositoriesForUser": { group: "default", points: 1 }, "createProject": { group: "default", points: 1 }, "getProjects": { group: "default", points: 1 }, - "getPrebuilds": { group: "default", points: 1 }, + "deleteProject": { group: "default", points: 1 }, + "findPrebuilds": { group: "default", points: 1 }, + "getProjectOverview": { group: "default", points: 1 }, + "triggerPrebuild": { group: "default", points: 1 }, "setProjectConfiguration": { group: "default", points: 1 }, "fetchProjectRepositoryConfiguration": { group: "default", points: 1 }, "getContentBlobUploadUrl": { group: "default", points: 1 }, @@ -131,9 +134,6 @@ function readConfig(): RateLimiterConfig { accesHeadlessLogs: { group: "default", points: 1 }, - /** - * gitpod.io concerns - */ "adminAddStudentEmailDomain": { group: "default", points: 1 }, "adminGetAccountStatement": { group: "default", points: 1 }, "adminIsStudent": { group: "default", points: 1 }, diff --git a/components/server/src/bitbucket/bitbucket-repository-provider.ts b/components/server/src/bitbucket/bitbucket-repository-provider.ts index 728274fbe8721d..6d0f2020e92a40 100644 --- a/components/server/src/bitbucket/bitbucket-repository-provider.ts +++ b/components/server/src/bitbucket/bitbucket-repository-provider.ts @@ -4,7 +4,7 @@ * See License-AGPL.txt in the project root for license information. */ -import { Repository, User } from "@gitpod/gitpod-protocol"; +import { Branch, CommitInfo, Repository, User } from "@gitpod/gitpod-protocol"; import { inject, injectable } from 'inversify'; import { parseRepoUrl } from '../repohost/repo-url'; import { RepositoryProvider } from '../repohost/repository-provider'; @@ -25,4 +25,14 @@ export class BitbucketRepositoryProvider implements RepositoryProvider { const webUrl = repo.links!.html!.href; return { host, owner, name, cloneUrl, description, avatarUrl, webUrl }; } + + async getBranches(user: User, owner: string, repo: string): Promise { + // todo + return []; + } + + async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise { + // todo + return undefined; + } } diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 3942da61184b73..209b1ff3d1c6ea 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -79,6 +79,7 @@ import { HeadlessLogService } from './workspace/headless-log-service'; import { HeadlessLogController } from './workspace/headless-log-controller'; import { IAnalyticsWriter } from '@gitpod/gitpod-protocol/lib/analytics'; import { HeadlessLogServiceClient } from '@gitpod/content-service/lib/headless-log_grpc_pb'; +import { ProjectsService } from './projects/projects-service'; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Env).toSelf().inSingletonScope(); @@ -205,4 +206,6 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(HeadlessLogService).toSelf().inSingletonScope(); bind(HeadlessLogController).toSelf().inSingletonScope(); + + bind(ProjectsService).toSelf().inSingletonScope(); }); diff --git a/components/server/src/github/api.ts b/components/server/src/github/api.ts index a5ed379ddf5109..584979079cb2b2 100644 --- a/components/server/src/github/api.ts +++ b/components/server/src/github/api.ts @@ -9,7 +9,7 @@ import { Octokit, RestEndpointMethodTypes } from "@octokit/rest" import { OctokitResponse } from "@octokit/types" import { OctokitOptions } from "@octokit/core/dist-types/types" -import { User } from "@gitpod/gitpod-protocol" +import { Branch, CommitInfo, User } from "@gitpod/gitpod-protocol" import { injectable, inject } from 'inversify'; import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; import { GitHubScope } from './scopes'; @@ -241,11 +241,42 @@ export class GitHubRestApi { } public async getRepository(user: User, params: RestEndpointMethodTypes["repos"]["get"]["parameters"]): Promise { - const key = `getRepository:${params.owner}/${params.owner}:${user.id}` + const key = `getRepository:${params.owner}/${params.owner}:${user.id}`; const response = await this.runWithCache(key, user, (api) => api.repos.get(params)); return response.data; } + public async getBranches(user: User, params: RestEndpointMethodTypes["repos"]["listBranches"]["parameters"]): Promise { + const key = `getBranches:${params.owner}/${params.owner}:${user.id}`; + const listBranchesResponse = (await this.runWithCache(key, user, (api) => api.repos.listBranches(params))) as RestEndpointMethodTypes["repos"]["listBranches"]["response"]; + + const result: Branch[] = []; + + for (const branch of listBranchesResponse.data) { + const { commit: { sha } } = branch; + const commit = await this.getCommit(user, { ...params, ref: sha }); + result.push({ + name: branch.name, + commit + }); + } + + return result; + } + + public async getCommit(user: User, params: RestEndpointMethodTypes["repos"]["getCommit"]["parameters"]): Promise { + const key = `getCommit:${params.owner}/${params.owner}/${params.ref}:${user.id}`; + const getCommitResponse = (await this.runWithCache(key, user, (api) => api.repos.getCommit(params))) as RestEndpointMethodTypes["repos"]["getCommit"]["response"]; + const { sha, commit, author } = getCommitResponse.data; + return { + sha, + author: commit.author?.name || "nobody", + authorAvatarUrl: author?.avatar_url, + authorDate: commit.author?.date, + commitMessage: commit.message, + } + } + } export interface GitHubResult extends OctokitResponse { } @@ -327,7 +358,7 @@ export interface BranchRef { name: string commit: CommitRef protected: boolean - protection_url: string + protection_url?: string } export interface CommitDetails { diff --git a/components/server/src/github/github-repository-provider.ts b/components/server/src/github/github-repository-provider.ts index e7cafdd26654f5..b7a3b0b31d56bc 100644 --- a/components/server/src/github/github-repository-provider.ts +++ b/components/server/src/github/github-repository-provider.ts @@ -10,18 +10,30 @@ import { User, Repository } from "@gitpod/gitpod-protocol" import { GitHubRestApi } from "./api"; import { RepositoryProvider } from '../repohost/repository-provider'; import { parseRepoUrl } from '../repohost/repo-url'; +import { Branch, CommitInfo } from '@gitpod/gitpod-protocol/src/protocol'; @injectable() export class GithubRepositoryProvider implements RepositoryProvider { @inject(GitHubRestApi) protected readonly github: GitHubRestApi; - async getRepo(user: User, owner: string, name: string): Promise { - const repository = await this.github.getRepository(user, { owner, repo: name }); + async getRepo(user: User, owner: string, repo: string): Promise { + const repository = await this.github.getRepository(user, { owner, repo }); const cloneUrl = repository.clone_url; const host = parseRepoUrl(cloneUrl)!.host; const description = repository.description; const avatarUrl = repository.owner.avatar_url; const webUrl = repository.html_url; - return { host, owner, name, cloneUrl, description, avatarUrl, webUrl }; + const defaultBranch = repository.default_branch; + return { host, owner, name: repo, cloneUrl, description, avatarUrl, webUrl, defaultBranch }; + } + + async getBranches(user: User, owner: string, repo: string): Promise { + const branches = await this.github.getBranches(user, { repo, owner }); + return branches; + } + + async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise { + const commit = await this.github.getCommit(user, { repo, owner, ref }); + return commit; } } diff --git a/components/server/src/gitlab/gitlab-repository-provider.ts b/components/server/src/gitlab/gitlab-repository-provider.ts index 525a16020a1d07..5fe272b2832db0 100644 --- a/components/server/src/gitlab/gitlab-repository-provider.ts +++ b/components/server/src/gitlab/gitlab-repository-provider.ts @@ -6,7 +6,7 @@ import { injectable, inject } from 'inversify'; -import { User, Repository } from "@gitpod/gitpod-protocol" +import { User, Repository, Branch, CommitInfo } from "@gitpod/gitpod-protocol" import { GitLabApi, GitLab } from "./api"; import { RepositoryProvider } from '../repohost/repository-provider'; import { parseRepoUrl } from '../repohost/repo-url'; @@ -29,4 +29,14 @@ export class GitlabRepositoryProvider implements RepositoryProvider { const webUrl = response.web_url; return { host, owner, name, cloneUrl, description, avatarUrl, webUrl }; } + + async getBranches(user: User, owner: string, repo: string): Promise { + // todo + return []; + } + + async getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise { + // todo + return undefined; + } } diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts new file mode 100644 index 00000000000000..8a0e5dce6ae88c --- /dev/null +++ b/components/server/src/projects/projects-service.ts @@ -0,0 +1,162 @@ +/** + * 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 { DBWithTracing, ProjectDB, TeamDB, TracedWorkspaceDB, WorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { Branch, CommitInfo, CreateProjectParams, FindPrebuildsParams, PrebuildInfo, PrebuiltWorkspace, Project, User } from "@gitpod/gitpod-protocol"; +import { HostContextProvider } from "../auth/host-context-provider"; +import { parseRepoUrl } from "../repohost"; + +@injectable() +export class ProjectsService { + + @inject(ProjectDB) protected readonly projectDB: ProjectDB; + @inject(TeamDB) protected readonly teamDB: TeamDB; + @inject(TracedWorkspaceDB) protected readonly workspaceDb: DBWithTracing; + @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; + + async getProject(projectId: string): Promise { + return this.projectDB.findProjectById(projectId); + } + + async getProjects(teamId: string): Promise { + const projects = await this.projectDB.findProjectsByTeam(teamId); + return projects; + } + + async getProjectOverview(user: User, teamId: string, projectName: string): Promise { + const project = await this.projectDB.findProjectByTeamAndName(teamId, projectName); + if (!project) { + return undefined; + } + const branches = await this.getBranchDetails(user, project); + return { branches }; + } + + protected getRepositoryProvider(project: Project) { + const parsedUrl = parseRepoUrl(project.cloneUrl); + const repositoryProvider = parsedUrl && this.hostContextProvider.get(parsedUrl.host)?.services?.repositoryProvider; + return repositoryProvider; + } + + async getBranchDetails(user: User, project: Project): Promise { + const parsedUrl = parseRepoUrl(project.cloneUrl); + if (!parsedUrl) { + return []; + } + const { owner, repo } = parsedUrl; + const repositoryProvider = this.getRepositoryProvider(project); + if (!repositoryProvider) { + return []; + } + const repository = await repositoryProvider.getRepo(user, owner, repo); + const branches = await repositoryProvider.getBranches(user, owner, repo); + + const result: Project.BranchDetails[] = []; + for (const branch of branches) { + const { name, commit } = branch; + result.push({ + name, + url: `${repository.webUrl}/tree/${branch.name}`, // todo: compute in repositoryProvider + changeAuthor: commit.author, + changeDate: commit.authorDate, + changeHash: commit.sha, + changeTitle: commit.commitMessage, + changeAuthorAvatar: commit.authorAvatarUrl, + isDefault: repository.defaultBranch === branch.name, + changePR: "changePR", // todo: compute in repositoryProvider + changeUrl: "changeUrl", // todo: compute in repositoryProvider + }); + } + return result; + } + + async createProject({ name, cloneUrl, teamId, appInstallationId }: CreateProjectParams): Promise { + return this.projectDB.storeProject(Project.create({ name, cloneUrl, teamId, appInstallationId })); + } + + async deleteProject(projectId: string): Promise { + return this.projectDB.markDeleted(projectId); + } + + protected async getLastPrebuild(project: Project, branch: Branch): Promise { + const prebuilds = await this.workspaceDb.trace({}).findPrebuiltWorkspacesByProject(project.id, branch?.name); + const prebuild = prebuilds[prebuilds.length - 1]; + if (!prebuild) { + return undefined; + } + return await this.toPrebuildInfo(project, prebuild, branch.commit); + } + + async findPrebuilds(user: User, params: FindPrebuildsParams): Promise { + const { teamId, projectName, prebuildId } = params; + const project = await this.projectDB.findProjectByTeamAndName(teamId, projectName); + if (!project) { + return []; + } + const parsedUrl = parseRepoUrl(project.cloneUrl); + if (!parsedUrl) { + return []; + } + const { owner, repo, host } = parsedUrl; + const repositoryProvider = this.hostContextProvider.get(host)?.services?.repositoryProvider; + if (!repositoryProvider) { + return []; + } + + let prebuilds: PrebuiltWorkspace[] = []; + const result: PrebuildInfo[] = []; + + if (prebuildId) { + const pbws = await this.workspaceDb.trace({}).findPrebuiltWorkspacesById(prebuildId); + if (pbws) { + prebuilds.push(pbws); + } + } else { + const limit = params.latest ? 1 : undefined; + let branch = params.branch; + if (limit && !branch) { + const repository = await repositoryProvider.getRepo(user, owner, repo); + branch = repository.defaultBranch; + } + prebuilds = await this.workspaceDb.trace({}).findPrebuiltWorkspacesByProject(project.id, branch, limit); + } + + for (const prebuild of prebuilds) { + const commit = await repositoryProvider?.getCommitInfo(user, owner, repo, prebuild.commit); + if (commit) { + result.push(await this.toPrebuildInfo(project, prebuild, commit)); + } + } + return result; + } + + protected async toPrebuildInfo(project: Project, prebuild: PrebuiltWorkspace, commit: CommitInfo): Promise { + const { teamId, name: projectName } = project; + + return { + id: prebuild.id, + buildWorkspaceId: prebuild.buildWorkspaceId, + startedAt: prebuild.creationTime, + startedBy: "", // TODO + startedByAvatar: "", // TODO + teamId, + projectName, + branch: prebuild.branch || "unknown", + cloneUrl: prebuild.cloneURL, + status: prebuild.state, + changeAuthor: commit.author, + changeAuthorAvatar: commit.authorAvatarUrl, + changeDate: commit.authorDate || "", + changeHash: commit.sha, + changeTitle: commit.commitMessage, + // changePR + // changeUrl + branchPrebuildNumber: "42" + }; + } + +} diff --git a/components/server/src/repohost/repository-provider.ts b/components/server/src/repohost/repository-provider.ts index 6ee6a0b7efa1e5..6f8b1c5f0bb50c 100644 --- a/components/server/src/repohost/repository-provider.ts +++ b/components/server/src/repohost/repository-provider.ts @@ -5,9 +5,11 @@ */ -import { Repository, User } from "@gitpod/gitpod-protocol" +import { Branch, CommitInfo, Repository, User } from "@gitpod/gitpod-protocol" export const RepositoryProvider = Symbol('RepositoryProvider'); export interface RepositoryProvider { getRepo(user: User, owner: string, repo: string): Promise; + getBranches(user: User, owner: string, repo: string): Promise; + getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise } \ No newline at end of file diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 46622c5b0e388d..6e8ec8bc0167e7 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -7,7 +7,7 @@ import { BlobServiceClient } from "@gitpod/content-service/lib/blobs_grpc_pb"; import { DownloadUrlRequest, DownloadUrlResponse, UploadUrlRequest, UploadUrlResponse } from '@gitpod/content-service/lib/blobs_pb'; import { AppInstallationDB, UserDB, UserMessageViewsDB, WorkspaceDB, DBWithTracing, TracedWorkspaceDB, DBGitpodToken, DBUser, UserStorageResourcesDB, ProjectDB, TeamDB } from '@gitpod/gitpod-db/lib'; -import { AuthProviderEntry, AuthProviderInfo, Branding, CommitContext, Configuration, CreateWorkspaceMode, DisposableCollection, GetWorkspaceTimeoutResult, GitpodClient, GitpodServer, GitpodToken, GitpodTokenType, InstallPluginsParams, PermissionName, PortVisibility, PrebuiltWorkspace, PrebuiltWorkspaceContext, PreparePluginUploadParams, ResolvedPlugins, ResolvePluginsParams, SetWorkspaceTimeoutResult, StartPrebuildContext, StartWorkspaceResult, Terms, Token, UninstallPluginParams, User, UserEnvVar, UserEnvVarValue, UserInfo, WhitelistedRepository, Workspace, WorkspaceContext, WorkspaceCreationResult, WorkspaceImageBuild, WorkspaceInfo, WorkspaceInstance, WorkspaceInstancePort, WorkspaceInstanceUser, WorkspaceTimeoutDuration, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo, TeamMembershipInvite, CreateProjectParams, ProjectInfo, Project, ProviderRepository, PrebuildInfo, TeamMemberRole, WithDefaultConfig } from '@gitpod/gitpod-protocol'; +import { AuthProviderEntry, AuthProviderInfo, Branding, CommitContext, Configuration, CreateWorkspaceMode, DisposableCollection, GetWorkspaceTimeoutResult, GitpodClient, GitpodServer, GitpodToken, GitpodTokenType, InstallPluginsParams, PermissionName, PortVisibility, PrebuiltWorkspace, PrebuiltWorkspaceContext, PreparePluginUploadParams, ResolvedPlugins, ResolvePluginsParams, SetWorkspaceTimeoutResult, StartPrebuildContext, StartWorkspaceResult, Terms, Token, UninstallPluginParams, User, UserEnvVar, UserEnvVarValue, UserInfo, WhitelistedRepository, Workspace, WorkspaceContext, WorkspaceCreationResult, WorkspaceImageBuild, WorkspaceInfo, WorkspaceInstance, WorkspaceInstancePort, WorkspaceInstanceUser, WorkspaceTimeoutDuration, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo, TeamMembershipInvite, CreateProjectParams, Project, ProviderRepository, PrebuildInfo, TeamMemberRole, WithDefaultConfig, FindPrebuildsParams } from '@gitpod/gitpod-protocol'; import { AccountStatement } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; import { AdminBlockUserRequest, AdminGetListRequest, AdminGetListResult, AdminGetWorkspacesRequest, AdminModifyPermanentWorkspaceFeatureFlagRequest, AdminModifyRoleOrPermissionRequest, WorkspaceAndInstance } from '@gitpod/gitpod-protocol/lib/admin-protocol'; import { GetLicenseInfoResult, LicenseFeature, LicenseValidationResult } from '@gitpod/gitpod-protocol/lib/license-protocol'; @@ -51,6 +51,7 @@ import { WorkspaceStarter } from './workspace-starter'; import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log"; import { HeadlessLogService } from "./headless-log-service"; import { InvalidGitpodYMLError } from "./config-provider"; +import { ProjectsService } from "../projects/projects-service"; @injectable() export class GitpodServerImpl implements GitpodServer, Disposable { @@ -95,6 +96,8 @@ export class GitpodServerImpl { - this.checkUser("getProviderRepositoriesForUser"); + this.checkAndBlockUser("getProviderRepositoriesForUser"); // Note: this operation is per-user only, hence needs no resource guard + + // implemented in EE return []; } - - public async createProject(params: CreateProjectParams): Promise { - this.checkAndBlockUser("createProject"); - const { name, cloneUrl, teamId, appInstallationId } = params; + async createProject(params: CreateProjectParams): Promise { + this.checkUser("createProject"); // Anyone who can read a team's information (i.e. any team member) can create a new project. - await this.guardTeamOperation(teamId, "get"); - return this.projectDB.storeProject(Project.create({name, cloneUrl, teamId, appInstallationId})); + await this.guardTeamOperation(params.teamId, "get"); + return this.projectsService.createProject(params); } - - public async getProjects(teamId: string): Promise { + async deleteProject(projectId: string): Promise { + this.checkUser("deleteProject"); + const project = await this.projectsService.getProject(projectId); + if (!project) { + return; + } + await this.guardTeamOperation(project.teamId, "delete"); // this is actually not deletion of a team ;-) + return this.projectsService.deleteProject(projectId); + } + async getProjects(teamId: string): Promise { this.checkUser("getProjects"); await this.guardTeamOperation(teamId, "get"); - return this.projectDB.findProjectsByTeam(teamId); + + return this.projectsService.getProjects(teamId); } + async findPrebuilds(params: FindPrebuildsParams): Promise { + const user = this.checkAndBlockUser("getPrebuilds"); + await this.guardTeamOperation(params.teamId, "get"); - public async getPrebuilds(teamId: string, projectId: string): Promise { - this.checkUser("getPrebuilds"); + return this.projectsService.findPrebuilds(user, params); + } + async getProjectOverview(teamId: string, projectName: string): Promise { + const user = this.checkAndBlockUser("getProjectOverview"); await this.guardTeamOperation(teamId, "get"); - return []; + + return this.projectsService.getProjectOverview(user, teamId, projectName); } + async triggerPrebuild(projectId: string, branch: string): Promise { + this.checkAndBlockUser("triggerPrebuild"); + + // implemented in EE + } + public async setProjectConfiguration(projectId: string, configString: string): Promise { this.checkAndBlockUser("setProjectConfiguration"); @@ -1974,4 +1998,5 @@ export class GitpodServerImpl