diff --git a/components/dashboard/src/projects/ProjectSettings.tsx b/components/dashboard/src/projects/ProjectSettings.tsx index 20988b98f3bd3a..0a5d4065a6f381 100644 --- a/components/dashboard/src/projects/ProjectSettings.tsx +++ b/components/dashboard/src/projects/ProjectSettings.tsx @@ -22,6 +22,7 @@ import { useRefreshProjects } from "../data/projects/list-projects-query"; import { useToast } from "../components/toasts/Toasts"; import classNames from "classnames"; import { InputField } from "../components/forms/InputField"; +import { SelectInputField } from "../components/forms/SelectInputField"; export function ProjectSettingsPage(props: { project?: Project; children?: React.ReactNode }) { return ( @@ -81,15 +82,28 @@ export default function ProjectSettingsView() { async (settings: ProjectSettings) => { if (!project) return; + const oldSettings = { ...project.settings }; const newSettings = { ...project.settings, ...settings }; + setProject({ ...project, settings: newSettings }); try { await getGitpodService().server.updateProjectPartial({ id: project.id, settings: newSettings }); - setProject({ ...project, settings: newSettings }); + toast(`Project ${projectName} updated.`); } catch (error) { + setProject({ ...project, settings: oldSettings }); toast(error?.message || "Oh no, there was a problem with updating project settings."); } }, - [project, setProject, toast], + [project, setProject, toast, projectName], + ); + + const setPrebuildBranchStrategy = useCallback( + async (value: ProjectSettings.PrebuildBranchStrategy) => { + const prebuildDefaultBranchOnly = value === "defaultBranch"; + await updateProjectSettings({ + prebuildDefaultBranchOnly, + }); + }, + [updateProjectSettings], ); const setWorkspaceClass = useCallback( @@ -125,6 +139,8 @@ export default function ProjectSettingsView() { const enablePrebuilds = Project.isPrebuildsEnabled(project); + const prebuildBranchStrategy = Project.getPrebuildBranchStrategy(project); + return ( Project Name @@ -168,14 +184,27 @@ export default function ProjectSettingsView() { /> {enablePrebuilds && ( <> - -
- -
+ setPrebuildBranchStrategy(val as ProjectSettings.PrebuildBranchStrategy)} + > + + + {/* */} + + + { */ enablePrebuilds?: boolean; + /** + * @generated from field: optional bool prebuild_default_branch_only = 6; + */ + prebuildDefaultBranchOnly?: boolean; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -182,6 +187,7 @@ export class PrebuildSettings extends Message { { no: 3, name: "use_previous_prebuilds", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 4, name: "prebuild_every_nth", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, { no: 5, name: "enable_prebuilds", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, + { no: 6, name: "prebuild_default_branch_only", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): PrebuildSettings { @@ -592,3 +598,4 @@ export class DeleteProjectResponse extends Message { return proto3.util.equals(DeleteProjectResponse, a, b); } } + diff --git a/components/server/src/prebuilds/bitbucket-app.ts b/components/server/src/prebuilds/bitbucket-app.ts index a2fe1545068a5b..9486b4bbab9b55 100644 --- a/components/server/src/prebuilds/bitbucket-app.ts +++ b/components/server/src/prebuilds/bitbucket-app.ts @@ -136,11 +136,13 @@ export class BitbucketApp { commit: context.revision, }); const config = await this.prebuildManager.fetchConfig({ span }, user, context, project?.teamId); - if (!this.prebuildManager.shouldPrebuild({ config, project })) { + const prebuildPrecondition = this.prebuildManager.checkPrebuildPrecondition({ config, project, context }); + if (!prebuildPrecondition.shouldRun) { log.info("Bitbucket push event: No prebuild.", { config, context }); await this.webhookEvents.updateEvent(event.id, { prebuildStatus: "ignored_unconfigured", status: "processed", + message: prebuildPrecondition.reason, }); return undefined; } diff --git a/components/server/src/prebuilds/bitbucket-server-app.ts b/components/server/src/prebuilds/bitbucket-server-app.ts index 2da6a7c2c4882b..34f6be72d9ec2b 100644 --- a/components/server/src/prebuilds/bitbucket-server-app.ts +++ b/components/server/src/prebuilds/bitbucket-server-app.ts @@ -137,11 +137,13 @@ export class BitbucketServerApp { commit: context.revision, }); const config = await this.prebuildManager.fetchConfig({ span }, user, context, project?.teamId); - if (!this.prebuildManager.shouldPrebuild({ config, project })) { + const prebuildPrecondition = this.prebuildManager.checkPrebuildPrecondition({ config, project, context }); + if (!prebuildPrecondition.shouldRun) { log.info("Bitbucket Server push event: No prebuild.", { config, context }); await this.webhookEvents.updateEvent(event.id, { prebuildStatus: "ignored_unconfigured", status: "processed", + message: prebuildPrecondition.reason, }); return undefined; } diff --git a/components/server/src/prebuilds/github-app-rules.ts b/components/server/src/prebuilds/github-app-rules.ts index b9e316df8e5fb9..553f65be17c55f 100644 --- a/components/server/src/prebuilds/github-app-rules.ts +++ b/components/server/src/prebuilds/github-app-rules.ts @@ -34,13 +34,23 @@ export class GithubAppRules { return deepmerge(defaultConfig, cfg.github); } + /** + * + * @deprecated + */ public shouldRunPrebuild( config: WorkspaceConfig | undefined, isDefaultBranch: boolean, isPR: boolean, isFork: boolean, ): boolean { - if (!config) { + if (!config || !config._origin || config._origin !== "repo") { + // we demand an explicit gitpod config + return false; + } + + const hasPrebuildTask = !!config.tasks && config.tasks.find((t) => !!t.before || !!t.init || !!t.prebuild); + if (!hasPrebuildTask) { return false; } diff --git a/components/server/src/prebuilds/github-app.spec.ts b/components/server/src/prebuilds/github-app.spec.ts index 900f4ee7f2ac8b..4dfe5bf9e2ef8c 100644 --- a/components/server/src/prebuilds/github-app.spec.ts +++ b/components/server/src/prebuilds/github-app.spec.ts @@ -35,6 +35,7 @@ describe("GitHub app", () => { prebuilds: pbcfg, }, tasks: [{ init: "ls" }], + _origin: "repo", }; chai.assert.equal( diff --git a/components/server/src/prebuilds/github-app.ts b/components/server/src/prebuilds/github-app.ts index 0b76b5ab76a42e..1c4d2a15fab8dc 100644 --- a/components/server/src/prebuilds/github-app.ts +++ b/components/server/src/prebuilds/github-app.ts @@ -308,17 +308,20 @@ export class GithubApp { branch: context.ref, commit: context.revision, }); + const prebuildPrecondition = this.prebuildManager.checkPrebuildPrecondition({ config, project, context }); - const runPrebuild = - this.prebuildManager.shouldPrebuild({ config, project }) && - this.appRules.shouldRunPrebuild(config, branch == repo.default_branch, false, false); - if (!runPrebuild) { - const reason = `Not running prebuild, the user did not enable it for this context or did not configure prebuild task(s)`; + const shouldRun = Project.hasPrebuildSettings(project) + ? prebuildPrecondition.shouldRun + : this.appRules.shouldRunPrebuild(config, CommitContext.isDefaultBranch(context), false, false); + + if (!shouldRun) { + const reason = `GitHub push event: No prebuild.`; log.debug(logCtx, reason, { contextURL }); span.log({ "not-running": reason, config: config }); await this.webhookEvents.updateEvent(event.id, { prebuildStatus: "ignored_unconfigured", status: "processed", + message: prebuildPrecondition.reason, }); return; } @@ -537,10 +540,13 @@ export class GithubApp { const contextURL = pr.html_url; const isFork = pr.head.repo.id !== pr.base.repo.id; - const runPrebuild = - this.prebuildManager.shouldPrebuild({ config, project }) && - this.appRules.shouldRunPrebuild(config, false, true, isFork); - if (runPrebuild) { + const prebuildPrecondition = this.prebuildManager.checkPrebuildPrecondition({ config, project, context }); + + const shouldRun = Project.hasPrebuildSettings(project) + ? prebuildPrecondition.shouldRun + : this.appRules.shouldRunPrebuild(config, false, true, isFork); + + if (shouldRun) { const commitInfo = await this.getCommitInfo(user, ctx.payload.repository.html_url, pr.head.sha); const result = await this.prebuildManager.startPrebuild(tracecContext, { user, @@ -553,16 +559,11 @@ export class GithubApp { } return result; } else { - log.debug( - { userId: user.id }, - `Not running prebuild, the user did not enable it for this context or did not configure prebuild task(s)`, - null, - { - contextURL, - userId: user.id, - project, - }, - ); + log.debug({ userId: user.id }, `GitHub push event: No prebuild.`, { + contextURL, + userId: user.id, + project, + }); return; } } diff --git a/components/server/src/prebuilds/github-enterprise-app.ts b/components/server/src/prebuilds/github-enterprise-app.ts index def1c4c15a0b0b..bb451f04e98da6 100644 --- a/components/server/src/prebuilds/github-enterprise-app.ts +++ b/components/server/src/prebuilds/github-enterprise-app.ts @@ -168,15 +168,18 @@ export class GitHubEnterpriseApp { }); const config = await this.prebuildManager.fetchConfig({ span }, user, context, project?.teamId); - if ( - !this.prebuildManager.shouldPrebuild({ config, project }) || - !this.appRules.shouldRunPrebuild(config, context.ref === context.repository.defaultBranch, false, false) - ) { + const prebuildPrecondition = this.prebuildManager.checkPrebuildPrecondition({ config, project, context }); + + const shouldRun = Project.hasPrebuildSettings(project) + ? prebuildPrecondition.shouldRun + : this.appRules.shouldRunPrebuild(config, CommitContext.isDefaultBranch(context), false, false); + if (!shouldRun) { log.info("GitHub Enterprise push event: No prebuild.", { config, context }); await this.webhookEvents.updateEvent(event.id, { prebuildStatus: "ignored_unconfigured", status: "processed", + message: prebuildPrecondition.reason, }); return undefined; } diff --git a/components/server/src/prebuilds/gitlab-app.ts b/components/server/src/prebuilds/gitlab-app.ts index 843ecdd47802bf..f1144238abbaf0 100644 --- a/components/server/src/prebuilds/gitlab-app.ts +++ b/components/server/src/prebuilds/gitlab-app.ts @@ -160,12 +160,14 @@ export class GitLabApp { commit: context.revision, }); - const config = await this.prebuildManager.fetchConfig({ span }, user, context, project.teamId); - if (!this.prebuildManager.shouldPrebuild({ config, project })) { + const config = await this.prebuildManager.fetchConfig({ span }, user, context, project?.teamId); + const prebuildPrecondition = this.prebuildManager.checkPrebuildPrecondition({ config, project, context }); + if (!prebuildPrecondition.shouldRun) { log.info("GitLab push event: No prebuild.", { config, context }); await this.webhookEvents.updateEvent(event.id, { prebuildStatus: "ignored_unconfigured", status: "processed", + message: prebuildPrecondition.reason, }); return undefined; } diff --git a/components/server/src/prebuilds/prebuild-manager.spec.ts b/components/server/src/prebuilds/prebuild-manager.spec.ts new file mode 100644 index 00000000000000..399ad37c9f9c47 --- /dev/null +++ b/components/server/src/prebuilds/prebuild-manager.spec.ts @@ -0,0 +1,199 @@ +/** + * Copyright (c) 2023 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 { Container, ContainerModule } from "inversify"; +import "mocha"; +import * as chai from "chai"; +import { PrebuildManager } from "./prebuild-manager"; +import { TracedWorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { WorkspaceService } from "../workspace/workspace-service"; +import { HostContextProvider } from "../auth/host-context-provider"; +import { ConfigProvider } from "../workspace/config-provider"; +import { Config } from "../config"; +import { ProjectsService } from "../projects/projects-service"; +import { IncrementalPrebuildsService } from "./incremental-prebuilds-service"; +import { EntitlementService } from "../billing/entitlement-service"; +import { CommitContext, Project, ProjectSettings, Repository, WorkspaceConfig } from "@gitpod/gitpod-protocol"; + +const expect = chai.expect; + +const containerModule = new ContainerModule((bind) => { + bind(PrebuildManager).toSelf().inSingletonScope(); + + // #region: mocked dependencies of PrebuildManager + bind(TracedWorkspaceDB).toConstantValue({} as any); + bind(WorkspaceService).toConstantValue({} as any); + bind(HostContextProvider).toConstantValue({} as any); + bind(ConfigProvider).toConstantValue({} as any); + bind(Config).toConstantValue({} as any); + bind(ProjectsService).toConstantValue({} as any); + bind(IncrementalPrebuildsService).toConstantValue({} as any); + bind(EntitlementService).toConstantValue({} as any); + // #endregion +}); + +const container = new Container(); +container.load(containerModule); + +const createPrebuildManager = () => container.get(PrebuildManager); + +describe("PrebuildManager", () => { + const config: WorkspaceConfig = { + _origin: "repo", + tasks: [ + { + name: "foo", + init: "./foo", + }, + ], + }; + const context: CommitContext = { + title: "", + repository: { + defaultBranch: "main", + } as Repository, + revision: "", + ref: "main", + }; + const settings: ProjectSettings = { + enablePrebuilds: false, + }; + const project = { + settings, + } as Project; + + function clone(o: T, fn: (clone: T) => void) { + const clone = JSON.parse(JSON.stringify(o)); + fn(clone); + return clone; + } + + const checkPrebuildPreconditionCases = [ + { + title: "no-config", + shouldRun: false, + reason: "no-gitpod-config-in-repo", + config: clone(config, (c) => (c._origin = undefined)), + context, + project, + }, + { + title: "no-tasks", + shouldRun: false, + reason: "no-tasks-in-gitpod-config", + config: clone(config, (c) => (c.tasks = [])), + context, + project, + }, + { + title: "pre-existing-project/enable-by-default(1)", + shouldRun: true, + reason: "all-branches-selected", + config, + context, + project: clone(project, (p) => (p.settings = undefined)), + }, + { + title: "pre-existing-project/enable-by-default(2)", + shouldRun: true, + reason: "all-branches-selected", + config, + context, + project: clone(project, (p) => delete p.settings), + }, + { + title: "prebuilds-not-enabled", + shouldRun: false, + reason: "prebuilds-not-enabled", + config, + context, + project: clone(project, (p) => (p.settings!.enablePrebuilds = false)), + }, + { + title: "default-branch-only/matched(1)", + shouldRun: true, + reason: "default-branch-matched", + config, + context, + project: clone( + project, + (p) => + (p.settings = { + enablePrebuilds: true, + prebuildDefaultBranchOnly: true, + }), + ), + }, + { + title: "default-branch-only/matched(2)", + shouldRun: true, + reason: "default-branch-matched", + config, + context, + project: clone( + project, + (p) => + (p.settings = { + enablePrebuilds: true, + prebuildDefaultBranchOnly: undefined, + }), + ), + }, + { + title: "default-branch-only/unmatched", + shouldRun: false, + reason: "default-branch-unmatched", + config, + context: clone(context, (c) => (c.ref = "feature-branch")), + project: clone( + project, + (p) => + (p.settings = { + enablePrebuilds: true, + prebuildDefaultBranchOnly: true, + }), + ), + }, + { + title: "default-branch-only/default-branch-missing-in-context", + shouldRun: false, + reason: "default-branch-missing-in-commit-context", + config, + context: clone(context, (c) => delete c.repository.defaultBranch), + project: clone( + project, + (p) => + (p.settings = { + enablePrebuilds: true, + prebuildDefaultBranchOnly: true, + }), + ), + }, + { + title: "all-branches/matched", + shouldRun: true, + reason: "all-branches-selected", + config, + context: clone(context, (c) => (c.ref = "feature-branch")), + project: clone( + project, + (p) => + (p.settings = { + enablePrebuilds: true, + prebuildDefaultBranchOnly: false, + }), + ), + }, + ]; + + for (const { title, config, context, project, shouldRun, reason } of checkPrebuildPreconditionCases) { + it(`checkPrebuildPrecondition/${title}`, async () => { + const manager = createPrebuildManager(); + const precondition = manager.checkPrebuildPrecondition({ project, config, context }); + expect(precondition).to.deep.equal({ shouldRun, reason }); + }); + } +}); diff --git a/components/server/src/prebuilds/prebuild-manager.ts b/components/server/src/prebuilds/prebuild-manager.ts index f99e4436583926..35ef5e139270bb 100644 --- a/components/server/src/prebuilds/prebuild-manager.ts +++ b/components/server/src/prebuilds/prebuild-manager.ts @@ -4,7 +4,7 @@ * See License.AGPL.txt in the project root for license information. */ -import { DBWithTracing, TeamDB, TracedWorkspaceDB, WorkspaceDB } from "@gitpod/gitpod-db/lib"; +import { DBWithTracing, TracedWorkspaceDB, WorkspaceDB } from "@gitpod/gitpod-db/lib"; import { CommitContext, CommitInfo, @@ -59,7 +59,6 @@ export class PrebuildManager { @inject(Config) protected readonly config: Config; @inject(ProjectsService) protected readonly projectService: ProjectsService; @inject(IncrementalPrebuildsService) protected readonly incrementalPrebuildsService: IncrementalPrebuildsService; - @inject(TeamDB) protected readonly teamDB: TeamDB; @inject(EntitlementService) protected readonly entitlementService: EntitlementService; async abortPrebuildsForBranch(ctx: TraceContext, project: Project, user: User, branch: string): Promise { @@ -341,19 +340,50 @@ export class PrebuildManager { } } - shouldPrebuild(params: { config: WorkspaceConfig; project: Project }): boolean { - const { config, project } = params; + checkPrebuildPrecondition(params: { config: WorkspaceConfig; project: Project; context: CommitContext }): { + shouldRun: boolean; + reason: string; + } { + const { config, project, context } = params; if (!config || !config._origin || config._origin !== "repo") { // we demand an explicit gitpod config - return false; + return { shouldRun: false, reason: "no-gitpod-config-in-repo" }; } const hasPrebuildTask = !!config.tasks && config.tasks.find((t) => !!t.before || !!t.init || !!t.prebuild); if (!hasPrebuildTask) { - return false; + return { shouldRun: false, reason: "no-tasks-in-gitpod-config" }; + } + + const isPrebuildsEnabled = Project.isPrebuildsEnabled(project); + if (!isPrebuildsEnabled) { + return { shouldRun: false, reason: "prebuilds-not-enabled" }; + } + + const strategy = Project.getPrebuildBranchStrategy(project); + if (strategy === "allBranches") { + return { shouldRun: true, reason: "all-branches-selected" }; + } + + if (strategy === "defaultBranch") { + const defaultBranch = context.repository.defaultBranch; + if (!defaultBranch) { + log.debug("CommitContext is missing the default branch. Ignoring request.", { context }); + return { shouldRun: false, reason: "default-branch-missing-in-commit-context" }; + } + + if (CommitContext.isDefaultBranch(context)) { + return { shouldRun: true, reason: "default-branch-matched" }; + } + return { shouldRun: false, reason: "default-branch-unmatched" }; + } + + if (strategy === "selectedBranches") { + // TODO support "selectedBranches" next } - return Project.isPrebuildsEnabled(project); + log.debug("Unknown prebuild branch strategy. Ignoring request.", { context, config }); + return { shouldRun: false, reason: "unknown-strategy" }; } protected shouldPrebuildIncrementally(cloneUrl: string, project: Project): boolean { diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts index 0449ddf4d52a2e..c0ec8e7c976644 100644 --- a/components/server/src/projects/projects-service.ts +++ b/components/server/src/projects/projects-service.ts @@ -30,6 +30,7 @@ import { ScmService } from "./scm-service"; export class ProjectsService { public static PROJECT_SETTINGS_DEFAULTS: ProjectSettings = { enablePrebuilds: false, + prebuildDefaultBranchOnly: true, }; constructor( @@ -422,6 +423,7 @@ export class ProjectsService { prebuildId: we.prebuildId, projectId: we.projectId, status: we.prebuildStatus || we.status, + message: we.message, })); } }