diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index 29d4d45d96..c1cbded81e 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -858,11 +858,20 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl silenceNoProjects, ) + versionCommandRunner := events.NewVersionCommandRunner( + pullUpdater, + projectCommandBuilder, + projectCommandRunner, + parallelPoolSize, + silenceNoProjects, + ) + commentCommandRunnerByCmd := map[models.CommandName]events.CommentCommandRunner{ models.PlanCommand: planCommandRunner, models.ApplyCommand: applyCommandRunner, models.ApprovePoliciesCommand: approvePoliciesCommandRunner, models.UnlockCommand: unlockCommandRunner, + models.VersionCommand: versionCommandRunner, } commandRunner := &events.DefaultCommandRunner{ diff --git a/server/core/runtime/version_step_runner.go b/server/core/runtime/version_step_runner.go new file mode 100644 index 0000000000..d20ea919db --- /dev/null +++ b/server/core/runtime/version_step_runner.go @@ -0,0 +1,25 @@ +package runtime + +import ( + "path/filepath" + + "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/events/models" +) + +// VersionStepRunner runs a version command given a ctx +type VersionStepRunner struct { + TerraformExecutor TerraformExec + DefaultTFVersion *version.Version +} + +// Run ensures a given version for the executable, builds the args from the project context and then runs executable returning the result +func (v *VersionStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []string, path string, envs map[string]string) (string, error) { + tfVersion := v.DefaultTFVersion + if ctx.TerraformVersion != nil { + tfVersion = ctx.TerraformVersion + } + + versionCmd := []string{"version"} + return v.TerraformExecutor.RunCommandWithVersion(ctx.Log, filepath.Clean(path), versionCmd, envs, tfVersion, ctx.Workspace) +} diff --git a/server/core/runtime/version_step_runner_test.go b/server/core/runtime/version_step_runner_test.go new file mode 100644 index 0000000000..6279b4d037 --- /dev/null +++ b/server/core/runtime/version_step_runner_test.go @@ -0,0 +1,50 @@ +package runtime + +import ( + "testing" + + "github.com/hashicorp/go-version" + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/core/terraform/mocks" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +func TestRunVersionStep(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + workspace := "default" + + context := models.ProjectCommandContext{ + Log: logger, + EscapedCommentArgs: []string{"comment", "args"}, + Workspace: workspace, + RepoRelDir: ".", + User: models.User{Username: "username"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } + + terraform := mocks.NewMockClient() + tfVersion, _ := version.NewVersion("0.15.0") + tmpDir, cleanup := TempDir(t) + defer cleanup() + + s := &VersionStepRunner{ + TerraformExecutor: terraform, + DefaultTFVersion: tfVersion, + } + + t.Run("ensure runs", func(t *testing.T) { + _, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, []string{"version"}, map[string]string(nil), tfVersion, "default") + Ok(t, err) + }) +} diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index a0b4c17a6c..a236eb30d0 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -162,11 +162,20 @@ func setup(t *testing.T) *vcsmocks.MockClient { SilenceNoProjects, ) + versionCommandRunner := events.NewVersionCommandRunner( + pullUpdater, + projectCommandBuilder, + projectCommandRunner, + parallelPoolSize, + SilenceNoProjects, + ) + commentCommandRunnerByCmd := map[models.CommandName]events.CommentCommandRunner{ models.PlanCommand: planCommandRunner, models.ApplyCommand: applyCommandRunner, models.ApprovePoliciesCommand: approvePoliciesCommandRunner, models.UnlockCommand: unlockCommandRunner, + models.VersionCommand: versionCommandRunner, } preWorkflowHooksCommandRunner = mocks.NewMockPreWorkflowHooksCommandRunner() diff --git a/server/events/comment_parser.go b/server/events/comment_parser.go index 38c51de7b7..395516895c 100644 --- a/server/events/comment_parser.go +++ b/server/events/comment_parser.go @@ -67,6 +67,8 @@ type CommentBuilder interface { BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) string // BuildApplyComment builds an apply comment for the specified args. BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool) string + // BuildVersionComment builds a version comment for the specified args. + BuildVersionComment(repoRelDir string, workspace string, project string) string } // CommentParser implements CommentParsing @@ -165,7 +167,7 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen } // Need to have a plan, apply, approve_policy or unlock at this point. - if !e.stringInSlice(command, []string{models.PlanCommand.String(), models.ApplyCommand.String(), models.UnlockCommand.String(), models.ApprovePoliciesCommand.String()}) { + if !e.stringInSlice(command, []string{models.PlanCommand.String(), models.ApplyCommand.String(), models.UnlockCommand.String(), models.ApprovePoliciesCommand.String(), models.VersionCommand.String()}) { return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\n```", command)} } @@ -204,6 +206,13 @@ func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) Commen name = models.UnlockCommand flagSet = pflag.NewFlagSet(models.UnlockCommand.String(), pflag.ContinueOnError) flagSet.SetOutput(ioutil.Discard) + case models.VersionCommand.String(): + name = models.VersionCommand + flagSet = pflag.NewFlagSet(models.VersionCommand.String(), pflag.ContinueOnError) + flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before running version.") + flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run version in relative to root of repo, ex. 'child/dir'.") + flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", fmt.Sprintf("Print the version for this project. Refers to the name of the project configured in %s.", yaml.AtlantisYAMLFilename)) + flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") default: return CommentParseResult{CommentResponse: fmt.Sprintf("Error: unknown command %q – this is a bug", command)} } @@ -285,6 +294,12 @@ func (e *CommentParser) BuildApplyComment(repoRelDir string, workspace string, p return fmt.Sprintf("%s %s%s", atlantisExecutable, models.ApplyCommand.String(), flags) } +// BuildVersionComment builds a version comment for the specified args. +func (e *CommentParser) BuildVersionComment(repoRelDir string, workspace string, project string) string { + flags := e.buildFlags(repoRelDir, workspace, project, false) + return fmt.Sprintf("%s %s%s", atlantisExecutable, models.VersionCommand.String(), flags) +} + func (e *CommentParser) buildFlags(repoRelDir string, workspace string, project string, autoMergeDisabled bool) string { // Add quotes if dir has spaces. if strings.Contains(repoRelDir, " ") { @@ -389,6 +404,7 @@ Commands: {{- end }} unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. + version Print the output of 'terraform version' help View help. Flags: diff --git a/server/events/comment_parser_test.go b/server/events/comment_parser_test.go index e38310911d..5bf664714b 100644 --- a/server/events/comment_parser_test.go +++ b/server/events/comment_parser_test.go @@ -576,7 +576,7 @@ func TestParse_Parsing(t *testing.T) { } } -func TestBuildPlanApplyComment(t *testing.T) { +func TestBuildPlanApplyVersionComment(t *testing.T) { cases := []struct { repoRelDir string workspace string @@ -585,77 +585,87 @@ func TestBuildPlanApplyComment(t *testing.T) { commentArgs []string expPlanFlags string expApplyFlags string + expVersionFlags string }{ { - repoRelDir: ".", - workspace: "default", - project: "", - commentArgs: nil, - expPlanFlags: "-d .", - expApplyFlags: "-d .", - }, - { - repoRelDir: "dir", - workspace: "default", - project: "", - commentArgs: nil, - expPlanFlags: "-d dir", - expApplyFlags: "-d dir", - }, - { - repoRelDir: ".", - workspace: "workspace", - project: "", - commentArgs: nil, - expPlanFlags: "-w workspace", - expApplyFlags: "-w workspace", - }, - { - repoRelDir: "dir", - workspace: "workspace", - project: "", - commentArgs: nil, - expPlanFlags: "-d dir -w workspace", - expApplyFlags: "-d dir -w workspace", - }, - { - repoRelDir: ".", - workspace: "default", - project: "project", - commentArgs: nil, - expPlanFlags: "-p project", - expApplyFlags: "-p project", - }, - { - repoRelDir: "dir", - workspace: "workspace", - project: "project", - commentArgs: nil, - expPlanFlags: "-p project", - expApplyFlags: "-p project", - }, - { - repoRelDir: ".", - workspace: "default", - project: "", - commentArgs: []string{`"arg1"`, `"arg2"`}, - expPlanFlags: "-d . -- arg1 arg2", - expApplyFlags: "-d .", - }, - { - repoRelDir: "dir", - workspace: "workspace", - project: "", - commentArgs: []string{`"arg1"`, `"arg2"`, `arg3`}, - expPlanFlags: "-d dir -w workspace -- arg1 arg2 arg3", - expApplyFlags: "-d dir -w workspace", - }, - { - repoRelDir: "dir with spaces", - workspace: "default", - project: "", - expPlanFlags: "-d \"dir with spaces\"", - expApplyFlags: "-d \"dir with spaces\"", + repoRelDir: ".", + workspace: "default", + project: "", + commentArgs: nil, + expPlanFlags: "-d .", + expApplyFlags: "-d .", + expVersionFlags: "-d .", + }, + { + repoRelDir: "dir", + workspace: "default", + project: "", + commentArgs: nil, + expPlanFlags: "-d dir", + expApplyFlags: "-d dir", + expVersionFlags: "-d dir", + }, + { + repoRelDir: ".", + workspace: "workspace", + project: "", + commentArgs: nil, + expPlanFlags: "-w workspace", + expApplyFlags: "-w workspace", + expVersionFlags: "-w workspace", + }, + { + repoRelDir: "dir", + workspace: "workspace", + project: "", + commentArgs: nil, + expPlanFlags: "-d dir -w workspace", + expApplyFlags: "-d dir -w workspace", + expVersionFlags: "-d dir -w workspace", + }, + { + repoRelDir: ".", + workspace: "default", + project: "project", + commentArgs: nil, + expPlanFlags: "-p project", + expApplyFlags: "-p project", + expVersionFlags: "-p project", + }, + { + repoRelDir: "dir", + workspace: "workspace", + project: "project", + commentArgs: nil, + expPlanFlags: "-p project", + expApplyFlags: "-p project", + expVersionFlags: "-p project", + }, + { + repoRelDir: ".", + workspace: "default", + project: "", + commentArgs: []string{`"arg1"`, `"arg2"`}, + expPlanFlags: "-d . -- arg1 arg2", + expApplyFlags: "-d .", + expVersionFlags: "-d .", + }, + { + repoRelDir: "dir", + workspace: "workspace", + project: "", + commentArgs: []string{`"arg1"`, `"arg2"`, `arg3`}, + expPlanFlags: "-d dir -w workspace -- arg1 arg2 arg3", + expApplyFlags: "-d dir -w workspace", + expVersionFlags: "-d dir -w workspace", + }, + { + repoRelDir: "dir with spaces", + workspace: "default", + project: "", + expPlanFlags: "-d \"dir with spaces\"", + expApplyFlags: "-d \"dir with spaces\"", + expVersionFlags: "-d \"dir with spaces\"", }, { repoRelDir: "dir", @@ -665,12 +675,13 @@ func TestBuildPlanApplyComment(t *testing.T) { commentArgs: []string{`"arg1"`, `"arg2"`, `arg3`}, expPlanFlags: "-d dir -w workspace -- arg1 arg2 arg3", expApplyFlags: "-d dir -w workspace --auto-merge-disabled", + expVersionFlags: "-d dir -w workspace", }, } for _, c := range cases { t.Run(c.expPlanFlags, func(t *testing.T) { - for _, cmd := range []models.CommandName{models.PlanCommand, models.ApplyCommand} { + for _, cmd := range []models.CommandName{models.PlanCommand, models.ApplyCommand, models.VersionCommand} { switch cmd { case models.PlanCommand: actComment := commentParser.BuildPlanComment(c.repoRelDir, c.workspace, c.project, c.commentArgs) @@ -678,6 +689,9 @@ func TestBuildPlanApplyComment(t *testing.T) { case models.ApplyCommand: actComment := commentParser.BuildApplyComment(c.repoRelDir, c.workspace, c.project, c.autoMergeDisabled) Equals(t, fmt.Sprintf("atlantis apply %s", c.expApplyFlags), actComment) + case models.VersionCommand: + actComment := commentParser.BuildVersionComment(c.repoRelDir, c.workspace, c.project) + Equals(t, fmt.Sprintf("atlantis version %s", c.expVersionFlags), actComment) } } }) @@ -715,6 +729,7 @@ Commands: To only apply a specific plan, use the -d, -w and -p flags. unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. + version Print the output of 'terraform version' help View help. Flags: @@ -741,6 +756,7 @@ Commands: To plan a specific project, use the -d, -w and -p flags. unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. + version Print the output of 'terraform version' help View help. Flags: diff --git a/server/events/markdown_renderer.go b/server/events/markdown_renderer.go index 4aba2f210b..55d86dbee8 100644 --- a/server/events/markdown_renderer.go +++ b/server/events/markdown_renderer.go @@ -28,6 +28,7 @@ var ( applyCommandTitle = models.ApplyCommand.TitleString() policyCheckCommandTitle = models.PolicyCheckCommand.TitleString() approvePoliciesCommandTitle = models.ApprovePoliciesCommand.TitleString() + versionCommandTitle = models.VersionCommand.TitleString() // maxUnwrappedLines is the maximum number of lines the Terraform output // can be before we wrap it in an expandable template. maxUnwrappedLines = 12 @@ -119,6 +120,7 @@ func (m *MarkdownRenderer) renderProjectResults(results []models.ProjectResult, var resultsTmplData []projectResultTmplData numPlanSuccesses := 0 numPolicyCheckSuccesses := 0 + numVersionSuccesses := 0 for _, result := range results { resultData := projectResultTmplData{ @@ -166,6 +168,13 @@ func (m *MarkdownRenderer) renderProjectResults(results []models.ProjectResult, } else { resultData.Rendered = m.renderTemplate(applyUnwrappedSuccessTmpl, struct{ Output string }{result.ApplySuccess}) } + } else if result.VersionSuccess != "" { + if m.shouldUseWrappedTmpl(vcsHost, result.VersionSuccess) { + resultData.Rendered = m.renderTemplate(versionWrappedSuccessTmpl, struct{ Output string }{result.VersionSuccess}) + } else { + resultData.Rendered = m.renderTemplate(versionUnwrappedSuccessTmpl, struct{ Output string }{result.VersionSuccess}) + } + numVersionSuccesses++ } else { resultData.Rendered = "Found no template. This is a bug!" } @@ -182,6 +191,10 @@ func (m *MarkdownRenderer) renderProjectResults(results []models.ProjectResult, tmpl = singleProjectPlanSuccessTmpl case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses == 0: tmpl = singleProjectPlanUnsuccessfulTmpl + case len(resultsTmplData) == 1 && common.Command == versionCommandTitle && numVersionSuccesses > 0: + tmpl = singleProjectVersionSuccessTmpl + case len(resultsTmplData) == 1 && common.Command == versionCommandTitle && numVersionSuccesses == 0: + tmpl = singleProjectVersionUnsuccessfulTmpl case len(resultsTmplData) == 1 && common.Command == applyCommandTitle: tmpl = singleProjectApplyTmpl case common.Command == planCommandTitle, @@ -191,6 +204,8 @@ func (m *MarkdownRenderer) renderProjectResults(results []models.ProjectResult, tmpl = approveAllProjectsTmpl case common.Command == applyCommandTitle: tmpl = multiProjectApplyTmpl + case common.Command == versionCommandTitle: + tmpl = multiProjectVersionTmpl default: return "no template matched–this is a bug" } @@ -240,6 +255,10 @@ var singleProjectPlanSuccessTmpl = template.Must(template.New("").Parse( var singleProjectPlanUnsuccessfulTmpl = template.Must(template.New("").Parse( "{{$result := index .Results 0}}Ran {{.Command}} for dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}`\n\n" + "{{$result.Rendered}}\n" + logTmpl)) +var singleProjectVersionSuccessTmpl = template.Must(template.New("").Parse( + "{{$result := index .Results 0}}Ran {{.Command}} for {{ if $result.ProjectName }}project: `{{$result.ProjectName}}` {{ end }}dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}`\n\n{{$result.Rendered}}\n" + logTmpl)) +var singleProjectVersionUnsuccessfulTmpl = template.Must(template.New("").Parse( + "{{$result := index .Results 0}}Ran {{.Command}} for dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}`\n\n{{$result.Rendered}}\n" + logTmpl)) var approveAllProjectsTmpl = template.Must(template.New("").Funcs(sprig.TxtFuncMap()).Parse( "Approved Policies for {{ len .Results }} projects:\n\n" + "{{ range $result := .Results }}" + @@ -269,6 +288,16 @@ var multiProjectApplyTmpl = template.Must(template.New("").Funcs(sprig.TxtFuncMa "{{$result.Rendered}}\n\n" + "---\n{{end}}" + logTmpl)) +var multiProjectVersionTmpl = template.Must(template.New("").Funcs(sprig.TxtFuncMap()).Parse( + "Ran {{.Command}} for {{ len .Results }} projects:\n\n" + + "{{ range $result := .Results }}" + + "1. {{ if $result.ProjectName }}project: `{{$result.ProjectName}}` {{ end }}dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}`\n" + + "{{end}}\n" + + "{{ range $i, $result := .Results }}" + + "### {{add $i 1}}. {{ if $result.ProjectName }}project: `{{$result.ProjectName}}` {{ end }}dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}`\n" + + "{{$result.Rendered}}\n\n" + + "---\n{{end}}" + + logTmpl)) var planSuccessUnwrappedTmpl = template.Must(template.New("").Parse( "```diff\n" + "{{.TerraformOutput}}\n" + @@ -326,6 +355,13 @@ var applyWrappedSuccessTmpl = template.Must(template.New("").Parse( "{{.Output}}\n" + "```\n" + "")) +var versionUnwrappedSuccessTmpl = template.Must(template.New("").Parse("```\n{{.Output}}```")) +var versionWrappedSuccessTmpl = template.Must(template.New("").Parse( + "
Show Output\n\n" + + "```\n" + + "{{.Output}}" + + "```\n" + + "
")) var unwrappedErrTmplText = "**{{.Command}} Error**\n" + "```\n" + "{{.Error}}\n" + diff --git a/server/events/mocks/mock_comment_building.go b/server/events/mocks/mock_comment_building.go index 2eb1e7aa82..4221402059 100644 --- a/server/events/mocks/mock_comment_building.go +++ b/server/events/mocks/mock_comment_building.go @@ -54,6 +54,21 @@ func (mock *MockCommentBuilder) BuildApplyComment(repoRelDir string, workspace s return ret0 } +func (mock *MockCommentBuilder) BuildVersionComment(repoRelDir string, workspace string, project string) string { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockCommentBuilder().") + } + params := []pegomock.Param{repoRelDir, workspace, project} + result := pegomock.GetGenericMockFrom(mock).Invoke("BuildVersionComment", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) + var ret0 string + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + } + return ret0 +} + func (mock *MockCommentBuilder) VerifyWasCalledOnce() *VerifierMockCommentBuilder { return &VerifierMockCommentBuilder{ mock: mock, @@ -130,8 +145,8 @@ func (c *MockCommentBuilder_BuildPlanComment_OngoingVerification) GetAllCaptured return } -func (verifier *VerifierMockCommentBuilder) BuildApplyComment(repoRelDir string, workspace string, project string) *MockCommentBuilder_BuildApplyComment_OngoingVerification { - params := []pegomock.Param{repoRelDir, workspace, project} +func (verifier *VerifierMockCommentBuilder) BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool) *MockCommentBuilder_BuildApplyComment_OngoingVerification { + params := []pegomock.Param{repoRelDir, workspace, project, autoMergeDisabled} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildApplyComment", params, verifier.timeout) return &MockCommentBuilder_BuildApplyComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } @@ -141,12 +156,51 @@ type MockCommentBuilder_BuildApplyComment_OngoingVerification struct { methodInvocations []pegomock.MethodInvocation } -func (c *MockCommentBuilder_BuildApplyComment_OngoingVerification) GetCapturedArguments() (string, string, string) { +func (c *MockCommentBuilder_BuildApplyComment_OngoingVerification) GetCapturedArguments() (string, string, string, bool) { + repoRelDir, workspace, project, autoMergeDisabled := c.GetAllCapturedArguments() + return repoRelDir[len(repoRelDir)-1], workspace[len(workspace)-1], project[len(project)-1], autoMergeDisabled[len(autoMergeDisabled)-1] +} + +func (c *MockCommentBuilder_BuildApplyComment_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string, _param3 []bool) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([]string, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(string) + } + _param2 = make([]string, len(c.methodInvocations)) + for u, param := range params[2] { + _param2[u] = param.(string) + } + _param3 = make([]bool, len(c.methodInvocations)) + for u, param := range params[3] { + _param3[u] = param.(bool) + } + } + return +} + +func (verifier *VerifierMockCommentBuilder) BuildVersionComment(repoRelDir string, workspace string, project string) *MockCommentBuilder_BuildVersionComment_OngoingVerification { + params := []pegomock.Param{repoRelDir, workspace, project} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildVersionComment", params, verifier.timeout) + return &MockCommentBuilder_BuildVersionComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockCommentBuilder_BuildVersionComment_OngoingVerification struct { + mock *MockCommentBuilder + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockCommentBuilder_BuildVersionComment_OngoingVerification) GetCapturedArguments() (string, string, string) { repoRelDir, workspace, project := c.GetAllCapturedArguments() return repoRelDir[len(repoRelDir)-1], workspace[len(workspace)-1], project[len(project)-1] } -func (c *MockCommentBuilder_BuildApplyComment_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string) { +func (c *MockCommentBuilder_BuildVersionComment_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string) { params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(params) > 0 { _param0 = make([]string, len(c.methodInvocations)) diff --git a/server/events/mocks/mock_project_command_builder.go b/server/events/mocks/mock_project_command_builder.go index 3f41fdc3ec..60b3bec185 100644 --- a/server/events/mocks/mock_project_command_builder.go +++ b/server/events/mocks/mock_project_command_builder.go @@ -102,6 +102,25 @@ func (mock *MockProjectCommandBuilder) BuildApprovePoliciesCommands(ctx *events. return ret0, ret1 } +func (mock *MockProjectCommandBuilder) BuildVersionCommands(ctx *events.CommandContext, comment *events.CommentCommand) ([]models.ProjectCommandContext, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") + } + params := []pegomock.Param{ctx, comment} + result := pegomock.GetGenericMockFrom(mock).Invoke("BuildVersionCommands", params, []reflect.Type{reflect.TypeOf((*[]models.ProjectCommandContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 []models.ProjectCommandContext + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].([]models.ProjectCommandContext) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + func (mock *MockProjectCommandBuilder) VerifyWasCalledOnce() *VerifierMockProjectCommandBuilder { return &VerifierMockProjectCommandBuilder{ mock: mock, @@ -258,3 +277,34 @@ func (c *MockProjectCommandBuilder_BuildApprovePoliciesCommands_OngoingVerificat } return } + +func (verifier *VerifierMockProjectCommandBuilder) BuildVersionCommands(ctx *events.CommandContext, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification { + params := []pegomock.Param{ctx, comment} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildVersionCommands", params, verifier.timeout) + return &MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification struct { + mock *MockProjectCommandBuilder + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification) GetCapturedArguments() (*events.CommandContext, *events.CommentCommand) { + ctx, comment := c.GetAllCapturedArguments() + return ctx[len(ctx)-1], comment[len(comment)-1] +} + +func (c *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*events.CommandContext, _param1 []*events.CommentCommand) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*events.CommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(*events.CommandContext) + } + _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(*events.CommentCommand) + } + } + return +} diff --git a/server/events/mocks/mock_project_command_runner.go b/server/events/mocks/mock_project_command_runner.go index deba757c83..052226a373 100644 --- a/server/events/mocks/mock_project_command_runner.go +++ b/server/events/mocks/mock_project_command_runner.go @@ -85,6 +85,21 @@ func (mock *MockProjectCommandRunner) ApprovePolicies(ctx models.ProjectCommandC return ret0 } +func (mock *MockProjectCommandRunner) Version(ctx models.ProjectCommandContext) models.ProjectResult { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") + } + params := []pegomock.Param{ctx} + result := pegomock.GetGenericMockFrom(mock).Invoke("Version", params, []reflect.Type{reflect.TypeOf((*models.ProjectResult)(nil)).Elem()}) + var ret0 models.ProjectResult + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.ProjectResult) + } + } + return ret0 +} + func (mock *MockProjectCommandRunner) VerifyWasCalledOnce() *VerifierMockProjectCommandRunner { return &VerifierMockProjectCommandRunner{ mock: mock, @@ -229,3 +244,30 @@ func (c *MockProjectCommandRunner_ApprovePolicies_OngoingVerification) GetAllCap } return } + +func (verifier *VerifierMockProjectCommandRunner) Version(ctx models.ProjectCommandContext) *MockProjectCommandRunner_Version_OngoingVerification { + params := []pegomock.Param{ctx} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Version", params, verifier.timeout) + return &MockProjectCommandRunner_Version_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandRunner_Version_OngoingVerification struct { + mock *MockProjectCommandRunner + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandRunner_Version_OngoingVerification) GetCapturedArguments() models.ProjectCommandContext { + ctx := c.GetAllCapturedArguments() + return ctx[len(ctx)-1] +} + +func (c *MockProjectCommandRunner_Version_OngoingVerification) GetAllCapturedArguments() (_param0 []models.ProjectCommandContext) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.ProjectCommandContext) + } + } + return +} diff --git a/server/events/models/models.go b/server/events/models/models.go index 459b67d3e7..8d2baf9e55 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -436,6 +436,7 @@ type ProjectResult struct { PlanSuccess *PlanSuccess PolicyCheckSuccess *PolicyCheckSuccess ApplySuccess string + VersionSuccess string ProjectName string } @@ -533,6 +534,10 @@ type PolicyCheckSuccess struct { HasDiverged bool } +type VersionSuccess struct { + VersionOutput string +} + // PullStatus is the current status of a pull request that is in progress. type PullStatus struct { // Projects are the projects that have been modified in this pull request. @@ -627,6 +632,8 @@ const ( ApprovePoliciesCommand // AutoplanCommand is a command to run terrafor plan on PR open/update if autoplan is enabled AutoplanCommand + // VersionCommand is a command to run terraform version. + VersionCommand // Adding more? Don't forget to update String() below ) @@ -649,6 +656,8 @@ func (c CommandName) String() string { return "policy_check" case ApprovePoliciesCommand: return "approve_policies" + case VersionCommand: + return "version" } return "" } diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 63a70d6832..da44a86b91 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -85,6 +85,13 @@ type ProjectApprovePoliciesCommandBuilder interface { BuildApprovePoliciesCommands(ctx *CommandContext, comment *CommentCommand) ([]models.ProjectCommandContext, error) } +type ProjectVersionCommandBuilder interface { + // BuildVersionCommands builds project Version commands for this ctx and comment. If + // comment doesn't specify one project then there may be multiple commands + // to be run. + BuildVersionCommands(ctx *CommandContext, comment *CommentCommand) ([]models.ProjectCommandContext, error) +} + //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_command_builder.go ProjectCommandBuilder // ProjectCommandBuilder builds commands that run on individual projects. @@ -92,6 +99,7 @@ type ProjectCommandBuilder interface { ProjectPlanCommandBuilder ProjectApplyCommandBuilder ProjectApprovePoliciesCommandBuilder + ProjectVersionCommandBuilder } // DefaultProjectCommandBuilder implements ProjectCommandBuilder. @@ -150,6 +158,14 @@ func (p *DefaultProjectCommandBuilder) BuildApprovePoliciesCommands(ctx *Command return p.buildAllProjectCommands(ctx, cmd) } +func (p *DefaultProjectCommandBuilder) BuildVersionCommands(ctx *CommandContext, cmd *CommentCommand) ([]models.ProjectCommandContext, error) { + if !cmd.IsForSpecificProject() { + return p.buildAllProjectCommands(ctx, cmd) + } + pac, err := p.buildProjectVersionCommand(ctx, cmd) + return pac, err +} + // buildPlanAllCommands builds plan contexts for all projects we determine were // modified in this ctx. func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, commentFlags []string, verbose bool) ([]models.ProjectCommandContext, error) { @@ -453,6 +469,47 @@ func (p *DefaultProjectCommandBuilder) buildProjectApplyCommand(ctx *CommandCont ) } +// buildProjectVersionCommand builds a version command for the single project +// identified by cmd. +func (p *DefaultProjectCommandBuilder) buildProjectVersionCommand(ctx *CommandContext, cmd *CommentCommand) ([]models.ProjectCommandContext, error) { + workspace := DefaultWorkspace + if cmd.Workspace != "" { + workspace = cmd.Workspace + } + + var projCtx []models.ProjectCommandContext + unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, workspace) + if err != nil { + return projCtx, err + } + defer unlockFn() + + // use the default repository workspace because it is the only one guaranteed to have an atlantis.yaml, + // other workspaces will not have the file if they are using pre_workflow_hooks to generate it dynamically + repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) + if os.IsNotExist(errors.Cause(err)) { + return projCtx, errors.New("no working directory found–did you run plan?") + } else if err != nil { + return projCtx, err + } + + repoRelDir := DefaultRepoRelDir + if cmd.RepoRelDir != "" { + repoRelDir = cmd.RepoRelDir + } + + return p.buildProjectCommandCtx( + ctx, + models.VersionCommand, + cmd.ProjectName, + cmd.Flags, + repoDir, + repoRelDir, + workspace, + cmd.Verbose, + ) +} + // buildProjectCommandCtx builds a context for a single or several projects identified // by the parameters. func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContext, diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index c22c8df267..14b43c3621 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -1121,3 +1121,88 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman Equals(t, models.PolicyCheckCommand, policyCheckCtx.CommandName) Equals(t, globalCfg.Workflows["default"].PolicyCheck.Steps, policyCheckCtx.Steps) } + +// Test building version command for multiple projects +func TestDefaultProjectCommandBuilder_BuildVersionCommand(t *testing.T) { + RegisterMockTestingT(t) + tmpDir, cleanup := DirStructure(t, map[string]interface{}{ + "workspace1": map[string]interface{}{ + "project1": map[string]interface{}{ + "main.tf": nil, + "workspace.tfplan": nil, + }, + "project2": map[string]interface{}{ + "main.tf": nil, + "workspace.tfplan": nil, + }, + }, + "workspace2": map[string]interface{}{ + "project1": map[string]interface{}{ + "main.tf": nil, + "workspace.tfplan": nil, + }, + "project2": map[string]interface{}{ + "main.tf": nil, + "workspace.tfplan": nil, + }, + }, + }) + defer cleanup() + // Initialize git repos in each workspace so that the .tfplan files get + // picked up. + runCmd(t, filepath.Join(tmpDir, "workspace1"), "git", "init") + runCmd(t, filepath.Join(tmpDir, "workspace2"), "git", "init") + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetPullDir( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest())). + ThenReturn(tmpDir, nil) + + logger := logging.NewNoopLogger(t) + + globalCfgArgs := valid.GlobalCfgArgs{ + AllowRepoCfg: false, + MergeableReq: false, + ApprovedReq: false, + UnDivergedReq: false, + } + + builder := events.NewProjectCommandBuilder( + false, + &yaml.ParserValidator{}, + &events.DefaultProjectFinder{}, + nil, + workingDir, + events.NewDefaultWorkingDirLocker(), + valid.NewGlobalCfgFromArgs(globalCfgArgs), + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{}, + false, + false, + "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl", + ) + + ctxs, err := builder.BuildVersionCommands( + &events.CommandContext{ + Log: logger, + }, + &events.CommentCommand{ + RepoRelDir: "", + Flags: nil, + Name: models.VersionCommand, + Verbose: false, + Workspace: "", + ProjectName: "", + }) + Ok(t, err) + Equals(t, 4, len(ctxs)) + Equals(t, "project1", ctxs[0].RepoRelDir) + Equals(t, "workspace1", ctxs[0].Workspace) + Equals(t, "project2", ctxs[1].RepoRelDir) + Equals(t, "workspace1", ctxs[1].Workspace) + Equals(t, "project1", ctxs[2].RepoRelDir) + Equals(t, "workspace2", ctxs[2].Workspace) + Equals(t, "project2", ctxs[3].RepoRelDir) + Equals(t, "workspace2", ctxs[3].Workspace) +} diff --git a/server/events/project_command_context_builder.go b/server/events/project_command_context_builder.go index 8b352a8a0a..da5a0810f3 100644 --- a/server/events/project_command_context_builder.go +++ b/server/events/project_command_context_builder.go @@ -57,6 +57,11 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext( steps = prjCfg.Workflow.Plan.Steps case models.ApplyCommand: steps = prjCfg.Workflow.Apply.Steps + case models.VersionCommand: + // Setting statically since there will only be one step + steps = []valid.Step{{ + StepName: "version", + }} } // If TerraformVersion not defined in config file look for a @@ -70,6 +75,7 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext( cmdName, cb.CommentBuilder.BuildApplyComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, prjCfg.AutoMergeDisabled), cb.CommentBuilder.BuildPlanComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, commentFlags), + cb.CommentBuilder.BuildVersionComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name), prjCfg, steps, prjCfg.PolicySets, @@ -127,6 +133,7 @@ func (cb *PolicyCheckProjectCommandContextBuilder) BuildProjectContext( models.PolicyCheckCommand, cb.CommentBuilder.BuildApplyComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, prjCfg.AutoMergeDisabled), cb.CommentBuilder.BuildPlanComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, commentFlags), + cb.CommentBuilder.BuildVersionComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name), prjCfg, steps, prjCfg.PolicySets, @@ -148,6 +155,7 @@ func newProjectCommandContext(ctx *CommandContext, cmd models.CommandName, applyCmd string, planCmd string, + versionCmd string, projCfg valid.MergedProjectCfg, steps []valid.Step, policySets valid.PolicySets, diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index d5d495fa9e..45f7c6b85b 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -100,6 +100,11 @@ type ProjectApprovePoliciesCommandRunner interface { ApprovePolicies(ctx models.ProjectCommandContext) models.ProjectResult } +type ProjectVersionCommandRunner interface { + // Version runs terraform version for the project described by ctx. + Version(ctx models.ProjectCommandContext) models.ProjectResult +} + // ProjectCommandRunner runs project commands. A project command is a command // for a specific TF project. type ProjectCommandRunner interface { @@ -107,6 +112,7 @@ type ProjectCommandRunner interface { ProjectApplyCommandRunner ProjectPolicyCheckCommandRunner ProjectApprovePoliciesCommandRunner + ProjectVersionCommandRunner } // DefaultProjectCommandRunner implements ProjectCommandRunner. @@ -118,6 +124,7 @@ type DefaultProjectCommandRunner struct { ShowStepRunner StepRunner ApplyStepRunner StepRunner PolicyCheckStepRunner StepRunner + VersionStepRunner StepRunner RunStepRunner CustomStepRunner EnvStepRunner EnvStepRunner PullApprovedChecker runtime.PullApprovedChecker @@ -181,6 +188,19 @@ func (p *DefaultProjectCommandRunner) ApprovePolicies(ctx models.ProjectCommandC } } +func (p *DefaultProjectCommandRunner) Version(ctx models.ProjectCommandContext) models.ProjectResult { + versionOut, failure, err := p.doVersion(ctx) + return models.ProjectResult{ + Command: models.VersionCommand, + Failure: failure, + Error: err, + VersionSuccess: versionOut, + RepoRelDir: ctx.RepoRelDir, + Workspace: ctx.Workspace, + ProjectName: ctx.ProjectName, + } +} + func (p *DefaultProjectCommandRunner) doApprovePolicies(ctx models.ProjectCommandContext) (*models.PolicyCheckSuccess, string, error) { // TODO: Make this a bit smarter @@ -370,6 +390,34 @@ func (p *DefaultProjectCommandRunner) doApply(ctx models.ProjectCommandContext) return strings.Join(outputs, "\n"), "", nil } +func (p *DefaultProjectCommandRunner) doVersion(ctx models.ProjectCommandContext) (versionOut string, failure string, err error) { + repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, ctx.Workspace) + if err != nil { + if os.IsNotExist(err) { + return "", "", errors.New("project has not been cloned–did you run plan?") + } + return "", "", err + } + absPath := filepath.Join(repoDir, ctx.RepoRelDir) + if _, err = os.Stat(absPath); os.IsNotExist(err) { + return "", "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} + } + + // Acquire internal lock for the directory we're going to operate in. + unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace) + if err != nil { + return "", "", err + } + defer unlockFn() + + outputs, err := p.runSteps(ctx.Steps, ctx, absPath) + if err != nil { + return "", "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) + } + + return strings.Join(outputs, "\n"), "", nil +} + func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx models.ProjectCommandContext, absPath string) ([]string, error) { var outputs []string envs := make(map[string]string) @@ -387,6 +435,8 @@ func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx models.Pr out, err = p.PolicyCheckStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "apply": out, err = p.ApplyStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) + case "version": + out, err = p.VersionStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "run": out, err = p.RunStepRunner.Run(ctx, step.RunCommand, absPath, envs) case "env": diff --git a/server/events/version_command_runner.go b/server/events/version_command_runner.go new file mode 100644 index 0000000000..b1b7e029bb --- /dev/null +++ b/server/events/version_command_runner.go @@ -0,0 +1,58 @@ +package events + +import "github.com/runatlantis/atlantis/server/events/models" + +func NewVersionCommandRunner( + pullUpdater *PullUpdater, + prjCmdBuilder ProjectVersionCommandBuilder, + prjCmdRunner ProjectVersionCommandRunner, + parallelPoolSize int, + silenceVCSStatusNoProjects bool, +) *VersionCommandRunner { + return &VersionCommandRunner{ + pullUpdater: pullUpdater, + prjCmdBuilder: prjCmdBuilder, + prjCmdRunner: prjCmdRunner, + parallelPoolSize: parallelPoolSize, + silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, + } +} + +type VersionCommandRunner struct { + pullUpdater *PullUpdater + prjCmdBuilder ProjectVersionCommandBuilder + prjCmdRunner ProjectVersionCommandRunner + parallelPoolSize int + // SilenceVCSStatusNoProjects is whether any plan should set commit status if no projects + // are found + silenceVCSStatusNoProjects bool +} + +func (v *VersionCommandRunner) Run(ctx *CommandContext, cmd *CommentCommand) { + var err error + var projectCmds []models.ProjectCommandContext + projectCmds, err = v.prjCmdBuilder.BuildVersionCommands(ctx, cmd) + if err != nil { + ctx.Log.Warn("Error %s", err) + } + + if len(projectCmds) == 0 { + ctx.Log.Info("no projects to run version in") + return + } + + // Only run commands in parallel if enabled + var result CommandResult + if v.isParallelEnabled(projectCmds) { + ctx.Log.Info("Running version in parallel") + result = runProjectCmdsParallel(projectCmds, v.prjCmdRunner.Version, v.parallelPoolSize) + } else { + result = runProjectCmds(projectCmds, v.prjCmdRunner.Version) + } + + v.pullUpdater.updatePull(ctx, cmd, result) +} + +func (v *VersionCommandRunner) isParallelEnabled(cmds []models.ProjectCommandContext) bool { + return len(cmds) > 0 && cmds[0].ParallelPolicyCheckEnabled +} diff --git a/server/server.go b/server/server.go index 9353d92306..93976dc315 100644 --- a/server/server.go +++ b/server/server.go @@ -473,6 +473,10 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { EnvStepRunner: &runtime.EnvStepRunner{ RunStepRunner: runStepRunner, }, + VersionStepRunner: &runtime.VersionStepRunner{ + TerraformExecutor: terraformClient, + DefaultTFVersion: defaultTfVersion, + }, PullApprovedChecker: vcsClient, WorkingDir: workingDir, Webhooks: webhooksManager, @@ -553,11 +557,20 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { userConfig.SilenceNoProjects, ) + versionCommandRunner := events.NewVersionCommandRunner( + pullUpdater, + projectCommandBuilder, + projectCommandRunner, + userConfig.ParallelPoolSize, + userConfig.SilenceNoProjects, + ) + commentCommandRunnerByCmd := map[models.CommandName]events.CommentCommandRunner{ models.PlanCommand: planCommandRunner, models.ApplyCommand: applyCommandRunner, models.ApprovePoliciesCommand: approvePoliciesCommandRunner, models.UnlockCommand: unlockCommandRunner, + models.VersionCommand: versionCommandRunner, } commandRunner := &events.DefaultCommandRunner{