diff --git a/action.yml b/action.yml index c8a8580a7..c7f1d88ab 100644 --- a/action.yml +++ b/action.yml @@ -86,6 +86,10 @@ inputs: description: Post plans as one comment required: false default: 'false' + reporting-strategy: + description: 'comments_per_run or latest_run_comment, anything else will default to original behavior of multiple comments' + required: false + default: 'comments_per_run' outputs: output: value: ${{ steps.digger.outputs.output }} @@ -186,6 +190,7 @@ runs: DIGGER_TOKEN: ${{ inputs.digger-token }} DIGGER_HOSTNAME: ${{ inputs.digger-hostname }} ACCUMULATE_PLANS: ${{ inputs.post-plans-as-one-comment == 'true' }} + REPORTING_STRATEGY: ${{ inputs.reporting-strategy }} run: | cd ${{ github.action_path }} go build -o digger ./cmd/digger @@ -203,6 +208,7 @@ runs: DIGGER_TOKEN: ${{ inputs.digger-token }} DIGGER_HOSTNAME: ${{ inputs.digger-hostname }} ACCUMULATE_PLANS: ${{ inputs.post-plans-as-one-comment == 'true' }} + REPORTING_STRATEGY: ${{ inputs.reporting-strategy }} id: digger shell: bash run: | diff --git a/cmd/digger/main.go b/cmd/digger/main.go index 5d22b2f8f..26b8c8c25 100644 --- a/cmd/digger/main.go +++ b/cmd/digger/main.go @@ -24,11 +24,12 @@ import ( "net/http" "os" "strings" + "time" "github.com/google/go-github/v53/github" ) -func gitHubCI(lock core_locking.Lock, policyChecker core_policy.Checker) { +func gitHubCI(lock core_locking.Lock, policyChecker core_policy.Checker, reportingStrategy reporting.ReportStrategy) { println("Using GitHub.") githubActor := os.Getenv("GITHUB_ACTOR") if githubActor != "" { @@ -108,8 +109,9 @@ func gitHubCI(lock core_locking.Lock, policyChecker core_policy.Checker) { planStorage := newPlanStorage(ghToken, repoOwner, repositoryName, githubActor, prNumber) reporter := &reporting.CiReporter{ - CiService: githubPrService, - PrNumber: prNumber, + CiService: githubPrService, + PrNumber: prNumber, + ReportStrategy: reportingStrategy, } currentDir, err := os.Getwd() if err != nil { @@ -136,7 +138,7 @@ func gitHubCI(lock core_locking.Lock, policyChecker core_policy.Checker) { }() } -func gitLabCI(lock core_locking.Lock, policyChecker core_policy.Checker) { +func gitLabCI(lock core_locking.Lock, policyChecker core_policy.Checker, reportingStrategy reporting.ReportStrategy) { println("Using GitLab.") projectNamespace := os.Getenv("CI_PROJECT_NAMESPACE") @@ -201,8 +203,9 @@ func gitLabCI(lock core_locking.Lock, policyChecker core_policy.Checker) { diggerProjectNamespace := gitLabContext.ProjectNamespace + "/" + gitLabContext.ProjectName planStorage := newPlanStorage("", "", "", gitLabContext.GitlabUserName, *gitLabContext.MergeRequestIId) reporter := &reporting.CiReporter{ - CiService: gitlabService, - PrNumber: *gitLabContext.MergeRequestIId, + CiService: gitlabService, + PrNumber: *gitLabContext.MergeRequestIId, + ReportStrategy: reportingStrategy, } allAppliesSuccess, atLeastOneApply, err := digger.RunCommandsPerProject(commandsToRunPerProject, &dependencyGraph, diggerProjectNamespace, gitLabContext.GitlabUserName, gitLabContext.EventType.String(), *gitLabContext.MergeRequestIId, gitlabService, lock, reporter, planStorage, policyChecker, currentDir) @@ -227,7 +230,7 @@ func gitLabCI(lock core_locking.Lock, policyChecker core_policy.Checker) { }() } -func azureCI(lock core_locking.Lock, policyChecker core_policy.Checker) { +func azureCI(lock core_locking.Lock, policyChecker core_policy.Checker, reportingStrategy reporting.ReportStrategy) { fmt.Println("> Azure CI detected") azureContext := os.Getenv("AZURE_CONTEXT") azureToken := os.Getenv("AZURE_TOKEN") @@ -279,8 +282,9 @@ func azureCI(lock core_locking.Lock, policyChecker core_policy.Checker) { diggerProjectNamespace := parsedAzureContext.BaseUrl + "/" + parsedAzureContext.ProjectName reporter := &reporting.CiReporter{ - CiService: azureService, - PrNumber: prNumber, + CiService: azureService, + PrNumber: prNumber, + ReportStrategy: reportingStrategy, } allAppliesSuccess, atLeastOneApply, err := digger.RunCommandsPerProject(commandsToRunPerProject, &dependencyGraph, diggerProjectNamespace, parsedAzureContext.BaseUrl, parsedAzureContext.EventType, prNumber, azureService, lock, reporter, planStorage, policyChecker, currentDir) if err != nil { @@ -338,6 +342,21 @@ func main() { } else { policyChecker = policy.NoOpPolicyChecker{} } + + var reportStrategy reporting.ReportStrategy + + if os.Getenv("REPORTING_STRATEGY") == "comments_per_run" || os.Getenv("ACCUMULATE_PLANS") == "true" { + reportStrategy = &reporting.CommentPerRunStrategy{ + TimeOfRun: time.Now(), + } + } else if os.Getenv("REPORTING_STRATEGY") == "latest_run_comment" { + reportStrategy = &reporting.LatestRunCommentStrategy{ + TimeOfRun: time.Now(), + } + } else { + reportStrategy = &reporting.MultipleCommentsStrategy{} + } + lock, err := locking.GetLock() if err != nil { fmt.Printf("Failed to create lock provider. %s\n", err) @@ -348,11 +367,11 @@ func main() { ci := digger.DetectCI() switch ci { case digger.GitHub: - gitHubCI(lock, policyChecker) + gitHubCI(lock, policyChecker, reportStrategy) case digger.GitLab: - gitLabCI(lock, policyChecker) + gitLabCI(lock, policyChecker, reportStrategy) case digger.Azure: - azureCI(lock, policyChecker) + azureCI(lock, policyChecker, reportStrategy) case digger.BitBucket: case digger.None: print("No CI detected.") diff --git a/pkg/azure/azure.go b/pkg/azure/azure.go index c5b2a329c..faa393e60 100644 --- a/pkg/azure/azure.go +++ b/pkg/azure/azure.go @@ -313,6 +313,44 @@ func (a *AzureReposService) IsMerged(prNumber int) (bool, error) { return *pullRequest.Status == git.PullRequestStatusValues.Completed, nil } +func (a *AzureReposService) EditComment(id interface{}, comment string) error { + threadId := id.(int) + comments := []git.Comment{ + { + Content: &comment, + }, + } + _, err := a.Client.UpdateThread(context.Background(), git.UpdateThreadArgs{ + Project: &a.ProjectName, + RepositoryId: &a.RepositoryId, + ThreadId: &threadId, + CommentThread: &git.GitPullRequestCommentThread{ + Comments: &comments, + }, + }) + return err +} + +func (a *AzureReposService) GetComments(prNumber int) ([]ci.Comment, error) { + comments, err := a.Client.GetComments(context.Background(), git.GetCommentsArgs{ + Project: &a.ProjectName, + RepositoryId: &a.RepositoryId, + PullRequestId: &prNumber, + }) + if err != nil { + return nil, err + } + var result []ci.Comment + for _, comment := range *comments { + result = append(result, ci.Comment{ + Id: *comment.Id, + Body: comment.Content, + }) + } + return result, nil + +} + func ProcessAzureReposEvent(azureEvent interface{}, diggerConfig *configuration.DiggerConfig, ciService ci.CIService) ([]configuration.Project, *configuration.Project, int, error) { var impactedProjects []configuration.Project var prNumber int diff --git a/pkg/ci/ci.go b/pkg/ci/ci.go index 591a9c3d5..3caaf5121 100644 --- a/pkg/ci/ci.go +++ b/pkg/ci/ci.go @@ -3,6 +3,8 @@ package ci type CIService interface { GetChangedFiles(prNumber int) ([]string, error) PublishComment(prNumber int, comment string) error + EditComment(id interface{}, comment string) error + GetComments(prNumber int) ([]Comment, error) // SetStatus set status of specified pull/merge request, status could be: "pending", "failure", "success" SetStatus(prNumber int, status string, statusContext string) error GetCombinedPullRequestStatus(prNumber int) (string, error) @@ -15,3 +17,8 @@ type CIService interface { IsClosed(prNumber int) (bool, error) GetUserTeams(organisation string, user string) ([]string, error) } + +type Comment struct { + Id interface{} + Body *string +} diff --git a/pkg/core/execution/execution.go b/pkg/core/execution/execution.go index 6cdc4cb66..2f0c6c063 100644 --- a/pkg/core/execution/execution.go +++ b/pkg/core/execution/execution.go @@ -162,13 +162,14 @@ func (d DiggerExecutor) Apply() (bool, error) { if step.Action == "apply" { stdout, stderr, err := d.TerraformExecutor.Apply(step.ExtraArgs, plansFilename, d.CommandEnvVars) applyOutput := cleanupTerraformApply(true, err, stdout, stderr) - comment := utils.GetTerraformOutputAsCollapsibleComment("Apply for **"+d.ProjectLock.LockId()+"**", applyOutput) - commentErr := d.Reporter.Report(comment) + formatter := utils.GetTerraformOutputAsCollapsibleComment("Apply for " + d.ProjectLock.LockId() + "") + + commentErr := d.Reporter.Report(applyOutput, formatter) if commentErr != nil { fmt.Printf("error publishing comment: %v", err) } if err != nil { - commentErr = d.Reporter.Report("Error during applying.") + commentErr = d.Reporter.Report(err.Error(), utils.AsCollapsibleComment("Error during applying.")) if commentErr != nil { fmt.Printf("error publishing comment: %v", err) } diff --git a/pkg/core/reporting/reporting.go b/pkg/core/reporting/reporting.go index 622a533eb..99b532185 100644 --- a/pkg/core/reporting/reporting.go +++ b/pkg/core/reporting/reporting.go @@ -1,5 +1,5 @@ package reporting type Reporter interface { - Report(report string) error + Report(report string, reportFormatting func(report string) string) error } diff --git a/pkg/core/utils/comments.go b/pkg/core/utils/comments.go index 10dca1540..e8413fd95 100644 --- a/pkg/core/utils/comments.go +++ b/pkg/core/utils/comments.go @@ -1,13 +1,22 @@ package utils -func GetTerraformOutputAsCollapsibleComment(summary string, collapsedComment string) string { - str := `
- ` + summary + ` +func GetTerraformOutputAsCollapsibleComment(summary string) func(string) string { - ` + "```terraform" + ` -` + collapsedComment + ` + return func(comment string) string { + return `
` + summary + ` + +` + "```terraform" + ` +` + comment + ` ` + "```" + `
` + } +} + +func AsCollapsibleComment(summary string) func(string) string { - return str + return func(comment string) string { + return `
` + summary + ` + ` + comment + ` +
` + } } diff --git a/pkg/digger/digger.go b/pkg/digger/digger.go index 23adee0a4..f97ac849b 100644 --- a/pkg/digger/digger.go +++ b/pkg/digger/digger.go @@ -73,9 +73,7 @@ func RunCommandsPerProject( policyChecker policy.Checker, workingDir string, ) (bool, bool, error) { - accumulatePlans := os.Getenv("ACCUMULATE_PLANS") == "true" appliesPerProject := make(map[string]bool) - plansToPublish := make([]string, 0) organisation := strings.Split(projectNamespace, "/")[0] @@ -93,7 +91,7 @@ func RunCommandsPerProject( if !allowedToPerformCommand { msg := fmt.Sprintf("User %s is not allowed to perform action: %s. Check your policies", requestedBy, command) - err := ciService.PublishComment(prNumber, msg) + err := reporter.Report(msg, utils.AsCollapsibleComment("Policy violation")) if err != nil { log.Printf("Error publishing comment: %v", err) } @@ -103,6 +101,7 @@ func RunCommandsPerProject( projectLock := &locking.PullRequestLock{ InternalLock: lock, + Reporter: reporter, CIService: ciService, ProjectName: projectCommands.ProjectName, ProjectNamespace: projectNamespace, @@ -153,14 +152,10 @@ func RunCommandsPerProject( return false, false, fmt.Errorf("failed to run digger plan command. %v", err) } else if planPerformed { if plan != "" { - comment := utils.GetTerraformOutputAsCollapsibleComment("Plan for **"+projectLock.LockId()+"**", plan) - if accumulatePlans { - plansToPublish = append(plansToPublish, comment) - } else { - err = reporter.Report(comment) - if err != nil { - log.Printf("Failed to report plan. %v", err) - } + formatter := utils.GetTerraformOutputAsCollapsibleComment("Plan for " + projectLock.LockId() + "") + err = reporter.Report(plan, formatter) + if err != nil { + log.Printf("Failed to report plan. %v", err) } } err := ciService.SetStatus(prNumber, "success", projectCommands.ProjectName+"/plan") @@ -193,7 +188,7 @@ func RunCommandsPerProject( if !isMergeable && !isMerged { comment := "Cannot perform Apply since the PR is not currently mergeable." fmt.Println(comment) - err = ciService.PublishComment(prNumber, comment) + err = reporter.Report(comment, utils.AsCollapsibleComment("Apply error")) if err != nil { fmt.Printf("error publishing comment: %v\n", err) } @@ -237,13 +232,6 @@ func RunCommandsPerProject( } } - if len(plansToPublish) > 0 { - err := reporter.Report(strings.Join(plansToPublish, "\n")) - if err != nil { - log.Printf("Failed to report plans. %v", err) - } - } - allAppliesSuccess := true for _, success := range appliesPerProject { if !success { diff --git a/pkg/digger/digger_test.go b/pkg/digger/digger_test.go index b04f958ba..e34658336 100644 --- a/pkg/digger/digger_test.go +++ b/pkg/digger/digger_test.go @@ -1,6 +1,7 @@ package digger import ( + "digger/pkg/ci" "digger/pkg/core/execution" "digger/pkg/core/models" "digger/pkg/reporting" @@ -105,6 +106,16 @@ func (m *MockPRManager) IsClosed(prNumber int) (bool, error) { return false, nil } +func (m *MockPRManager) GetComments(prNumber int) ([]ci.Comment, error) { + m.Commands = append(m.Commands, RunInfo{"GetComments", strconv.Itoa(prNumber), time.Now()}) + return []ci.Comment{}, nil +} + +func (m *MockPRManager) EditComment(id interface{}, comment string) error { + m.Commands = append(m.Commands, RunInfo{"EditComment", strconv.Itoa(id.(int)) + " " + comment, time.Now()}) + return nil +} + type MockProjectLock struct { Commands []RunInfo } @@ -170,8 +181,9 @@ func TestCorrectCommandExecutionWhenApplying(t *testing.T) { lock := &MockProjectLock{} planStorage := &MockPlanStorage{} reporter := &reporting.CiReporter{ - CiService: prManager, - PrNumber: 1, + CiService: prManager, + PrNumber: 1, + ReportStrategy: &reporting.MultipleCommentsStrategy{}, } executor := execution.DiggerExecutor{ ApplyStage: &models.Stage{ @@ -205,7 +217,7 @@ func TestCorrectCommandExecutionWhenApplying(t *testing.T) { commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage) - assert.Equal(t, []string{"RetrievePlan #.tfplan", "Lock ", "Init ", "Apply ", "LockId ", "PublishComment 1
\n Apply for ****\n\n ```terraform\n\n ```\n
", "LockId ", "Run echo"}, commandStrings) + assert.Equal(t, []string{"RetrievePlan #.tfplan", "Lock ", "Init ", "Apply ", "LockId ", "PublishComment 1
Apply for \n \n```terraform\n\n ```\n
", "LockId ", "Run echo"}, commandStrings) } func TestCorrectCommandExecutionWhenPlanning(t *testing.T) { diff --git a/pkg/github/github.go b/pkg/github/github.go index a72e39481..3f94874a3 100644 --- a/pkg/github/github.go +++ b/pkg/github/github.go @@ -68,6 +68,24 @@ func (svc *GithubService) PublishComment(prNumber int, comment string) error { return err } +func (svc *GithubService) GetComments(prNumber int) ([]ci.Comment, error) { + comments, _, err := svc.Client.Issues.ListComments(context.Background(), svc.Owner, svc.RepoName, prNumber, &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 100}}) + commentBodies := make([]ci.Comment, len(comments)) + for i, comment := range comments { + commentBodies[i] = ci.Comment{ + Id: *comment.ID, + Body: comment.Body, + } + } + return commentBodies, err +} + +func (svc *GithubService) EditComment(id interface{}, comment string) error { + commentId := id.(int64) + _, _, err := svc.Client.Issues.EditComment(context.Background(), svc.Owner, svc.RepoName, commentId, &github.IssueComment{Body: &comment}) + return err +} + func (svc *GithubService) SetStatus(prNumber int, status string, statusContext string) error { pr, _, err := svc.Client.PullRequests.Get(context.Background(), svc.Owner, svc.RepoName, prNumber) if err != nil { diff --git a/pkg/gitlab/gitlab.go b/pkg/gitlab/gitlab.go index d92547def..4ceaa61ed 100644 --- a/pkg/gitlab/gitlab.go +++ b/pkg/gitlab/gitlab.go @@ -1,6 +1,7 @@ package gitlab import ( + "digger/pkg/ci" "digger/pkg/configuration" "digger/pkg/core/models" "digger/pkg/utils" @@ -228,6 +229,16 @@ func (gitlabService GitLabService) IsMerged(mergeRequestID int) (bool, error) { return false, nil } +func (gitlabService GitLabService) EditComment(id interface{}, comment string) error { + //TODO implement me + return nil +} + +func (gitlabService GitLabService) GetComments(prNumber int) ([]ci.Comment, error) { + //TODO implement me + return nil, nil +} + func getMergeRequest(gitlabService GitLabService) *go_gitlab.MergeRequest { projectId := *gitlabService.Context.ProjectId mergeRequestIID := *gitlabService.Context.MergeRequestIId diff --git a/pkg/integration/integration_test.go b/pkg/integration/integration_test.go index 622ddd1cf..2f9ac5222 100644 --- a/pkg/integration/integration_test.go +++ b/pkg/integration/integration_test.go @@ -44,9 +44,15 @@ func getProjectLockForTests() (error, *locking.PullRequestLock) { repositoryName := "test_dynamodb_lock" ghToken := "token" githubPrService := dg_github.NewGitHubService(ghToken, repositoryName, repoOwner) + reporter := reporting.CiReporter{ + CiService: githubPrService, + PrNumber: 1, + ReportStrategy: &reporting.MultipleCommentsStrategy{}, + } projectLock := &locking.PullRequestLock{ InternalLock: &dynamoDbLock, + Reporter: &reporter, CIService: githubPrService, ProjectName: "test_dynamodb_lock", ProjectNamespace: repoOwner + "/" + repositoryName, @@ -413,6 +419,7 @@ func TestHappyPath(t *testing.T) { projectLock := &locking.PullRequestLock{ InternalLock: lock, + Reporter: reporter, CIService: githubPrService, ProjectName: "dev", ProjectNamespace: diggerProjectNamespace, @@ -453,6 +460,7 @@ func TestHappyPath(t *testing.T) { projectLock = &locking.PullRequestLock{ InternalLock: lock, + Reporter: reporter, CIService: githubPrService, ProjectName: "dev", ProjectNamespace: diggerProjectNamespace, @@ -476,6 +484,7 @@ func TestHappyPath(t *testing.T) { projectLock = &locking.PullRequestLock{ InternalLock: lock, + Reporter: reporter, CIService: githubPrService, ProjectName: "dev", ProjectNamespace: diggerProjectNamespace, @@ -568,6 +577,7 @@ func TestMultiEnvHappyPath(t *testing.T) { projectLock := &locking.PullRequestLock{ InternalLock: &dynamoDbLock, + Reporter: reporter, CIService: githubPrService, ProjectName: "digger_demo", ProjectNamespace: diggerProjectNamespace, @@ -607,6 +617,7 @@ func TestMultiEnvHappyPath(t *testing.T) { projectLock = &locking.PullRequestLock{ InternalLock: &dynamoDbLock, + Reporter: reporter, CIService: githubPrService, ProjectName: "digger_demo", ProjectNamespace: diggerProjectNamespace, @@ -630,6 +641,7 @@ func TestMultiEnvHappyPath(t *testing.T) { projectLock = &locking.PullRequestLock{ InternalLock: &dynamoDbLock, + Reporter: reporter, CIService: githubPrService, ProjectName: "digger_demo", ProjectNamespace: diggerProjectNamespace, @@ -827,6 +839,7 @@ workflows: projectLock = &locking.PullRequestLock{ InternalLock: lock, + Reporter: reporter, CIService: githubPrService, ProjectName: "dev", ProjectNamespace: diggerProjectNamespace, @@ -849,6 +862,7 @@ workflows: projectLock = &locking.PullRequestLock{ InternalLock: lock, + Reporter: reporter, CIService: githubPrService, ProjectName: "dev", ProjectNamespace: diggerProjectNamespace, diff --git a/pkg/locking/locking.go b/pkg/locking/locking.go index dc29a12fb..db66b30a1 100644 --- a/pkg/locking/locking.go +++ b/pkg/locking/locking.go @@ -6,6 +6,8 @@ import ( "digger/pkg/azure" "digger/pkg/ci" "digger/pkg/core/locking" + "digger/pkg/core/reporting" + "digger/pkg/core/utils" "digger/pkg/gcp" "errors" "fmt" @@ -26,6 +28,7 @@ import ( type PullRequestLock struct { InternalLock locking.Lock CIService ci.CIService + Reporter reporting.Reporter ProjectName string ProjectNamespace string PrNumber int @@ -71,7 +74,8 @@ func (projectLock *PullRequestLock) Lock() (bool, error) { } else { transactionIdStr := strconv.Itoa(*existingLockTransactionId) comment := "Project " + projectLock.projectId() + " locked by another PR #" + transactionIdStr + " (failed to acquire lock " + projectLock.ProjectNamespace + "). The locking plan must be applied or discarded before future plans can execute" - projectLock.CIService.PublishComment(projectLock.PrNumber, comment) + + err = projectLock.Reporter.Report(comment, utils.AsCollapsibleComment("Locking failed")) return false, nil } } @@ -84,7 +88,10 @@ func (projectLock *PullRequestLock) Lock() (bool, error) { if lockAcquired && !isNoOpLock { comment := "Project " + projectLock.projectId() + " has been locked by PR #" + strconv.Itoa(projectLock.PrNumber) - projectLock.CIService.PublishComment(projectLock.PrNumber, comment) + err = projectLock.Reporter.Report(comment, utils.AsCollapsibleComment("Locking successful")) + if err != nil { + println("failed to publish comment: " + err.Error()) + } println("project " + projectLock.projectId() + " locked successfully. PR # " + strconv.Itoa(projectLock.PrNumber)) } @@ -114,7 +121,7 @@ func (projectLock *PullRequestLock) verifyNoHangingLocks() (bool, error) { } transactionIdStr := strconv.Itoa(*transactionId) comment := "Project " + projectLock.projectId() + " locked by another PR #" + transactionIdStr + "(failed to acquire lock " + projectLock.ProjectName + "). The locking plan must be applied or discarded before future plans can execute" - projectLock.CIService.PublishComment(projectLock.PrNumber, comment) + err = projectLock.Reporter.Report(comment, utils.AsCollapsibleComment("Locking failed")) return false, nil } return true, nil @@ -139,7 +146,8 @@ func (projectLock *PullRequestLock) Unlock() (bool, error) { } if lockReleased { comment := "Project unlocked (" + projectLock.projectId() + ")." - projectLock.CIService.PublishComment(projectLock.PrNumber, comment) + projectLock.Reporter.Report(comment, utils.AsCollapsibleComment("Unlocking successful")) + println("Project unlocked") return true, nil } @@ -163,7 +171,7 @@ func (projectLock *PullRequestLock) ForceUnlock() error { if lockReleased { comment := "Project unlocked (" + projectLock.projectId() + ")." - projectLock.CIService.PublishComment(projectLock.PrNumber, comment) + projectLock.Reporter.Report(comment, utils.AsCollapsibleComment("Unlocking successful")) println("Project unlocked") } return nil diff --git a/pkg/locking/locking_test.go b/pkg/locking/locking_test.go index 11c040ddc..5f575a61f 100644 --- a/pkg/locking/locking_test.go +++ b/pkg/locking/locking_test.go @@ -9,9 +9,11 @@ import ( func TestLockingTwiceThrowsError(t *testing.T) { mockDynamoDB := utils.MockLock{make(map[string]int)} mockPrManager := utils.MockGithubPullrequestManager{} + reporter := utils.MockReporter{} pl := PullRequestLock{ InternalLock: &mockDynamoDB, CIService: &mockPrManager, + Reporter: &reporter, ProjectName: "a", ProjectNamespace: "", PrNumber: 1, @@ -23,6 +25,7 @@ func TestLockingTwiceThrowsError(t *testing.T) { pl2 := PullRequestLock{ InternalLock: &mockDynamoDB, CIService: &mockPrManager, + Reporter: &reporter, ProjectName: "a", ProjectNamespace: "", PrNumber: 2, diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go index 4788ab015..3e11fa2c9 100644 --- a/pkg/reporting/reporting.go +++ b/pkg/reporting/reporting.go @@ -1,12 +1,95 @@ package reporting -import "digger/pkg/ci" +import ( + "digger/pkg/ci" + "digger/pkg/core/utils" + "fmt" + "strings" + "time" +) type CiReporter struct { - CiService ci.CIService - PrNumber int + CiService ci.CIService + PrNumber int + ReportStrategy ReportStrategy } -func (ciReporter *CiReporter) Report(report string) error { - return ciReporter.CiService.PublishComment(ciReporter.PrNumber, report) +type ReportStrategy interface { + Report(ciService ci.CIService, PrNumber int, report string, reportFormatter func(report string) string) error +} + +type CommentPerRunStrategy struct { + TimeOfRun time.Time +} + +func (strategy *CommentPerRunStrategy) Report(ciService ci.CIService, PrNumber int, report string, reportFormatter func(report string) string) error { + comments, err := ciService.GetComments(PrNumber) + if err != nil { + return fmt.Errorf("error getting comments: %v", err) + } + + reportTitle := "Digger run report at " + strategy.TimeOfRun.Format("2006-01-02 15:04:05") + return upsertComment(ciService, PrNumber, report, reportFormatter, comments, reportTitle, err) +} + +func upsertComment(ciService ci.CIService, PrNumber int, report string, reportFormatter func(report string) string, comments []ci.Comment, reportTitle string, err error) error { + report = reportFormatter(report) + var commentIdForThisRun interface{} + var commentBody string + for _, comment := range comments { + if strings.Contains(*comment.Body, reportTitle) { + commentIdForThisRun = comment.Id + commentBody = *comment.Body + break + } + } + + if commentIdForThisRun == nil { + collapsibleComment := utils.AsCollapsibleComment(reportTitle)(report) + err := ciService.PublishComment(PrNumber, collapsibleComment) + if err != nil { + return fmt.Errorf("error publishing comment: %v", err) + } + return nil + } + + // strip first and last lines + lines := strings.Split(commentBody, "\n") + lines = lines[1 : len(lines)-1] + commentBody = strings.Join(lines, "\n") + + commentBody = commentBody + "\n\n" + report + "\n" + + completeComment := utils.AsCollapsibleComment(reportTitle)(commentBody) + + err = ciService.EditComment(commentIdForThisRun, completeComment) + + if err != nil { + return fmt.Errorf("error editing comment: %v", err) + } + return nil +} + +type LatestRunCommentStrategy struct { + TimeOfRun time.Time +} + +func (strategy *LatestRunCommentStrategy) Report(ciService ci.CIService, prNumber int, comment string, commentFormatting func(comment string) string) error { + comments, err := ciService.GetComments(prNumber) + if err != nil { + return fmt.Errorf("error getting comments: %v", err) + } + + reportTitle := "Digger latest run report" + return upsertComment(ciService, prNumber, comment, commentFormatting, comments, reportTitle, err) +} + +type MultipleCommentsStrategy struct{} + +func (strategy *MultipleCommentsStrategy) Report(ciService ci.CIService, PrNumber int, report string, formatter func(string) string) error { + return ciService.PublishComment(PrNumber, formatter(report)) +} + +func (ciReporter *CiReporter) Report(report string, reportFormatter func(report string) string) error { + return ciReporter.ReportStrategy.Report(ciReporter.CiService, ciReporter.PrNumber, report, reportFormatter) } diff --git a/pkg/reporting/reporting_test.go b/pkg/reporting/reporting_test.go new file mode 100644 index 000000000..e815fe55b --- /dev/null +++ b/pkg/reporting/reporting_test.go @@ -0,0 +1,229 @@ +package reporting + +import ( + "digger/pkg/ci" + "digger/pkg/core/utils" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestCommentPerRunStrategyReport(t *testing.T) { + timeOfRun := time.Now() + strategy := CommentPerRunStrategy{ + TimeOfRun: timeOfRun, + } + existingCommentForOtherRun := utils.AsCollapsibleComment("Digger run report at some other time")("") + + prNumber := 1 + ciService := &MockCiService{ + CommentsPerPr: map[int][]*ci.Comment{ + prNumber: { + { + Id: 1, + Body: &existingCommentForOtherRun, + }, + }, + }, + } + + report := "resource \"null_resource\" \"test\" {}" + reportFormatter := utils.GetTerraformOutputAsCollapsibleComment("run1") + err := strategy.Report(ciService, prNumber, report, reportFormatter) + if err != nil { + t.Errorf("Error: %v", err) + } + report2 := "resource \"null_resource\" \"test\" {}" + reportFormatter2 := utils.GetTerraformOutputAsCollapsibleComment("run2") + err = strategy.Report(ciService, prNumber, report2, reportFormatter2) + if err != nil { + t.Errorf("Error: %v", err) + } + report3 := "resource \"null_resource\" \"test\" {}" + reportFormatter3 := utils.GetTerraformOutputAsCollapsibleComment("run3") + err = strategy.Report(ciService, prNumber, report3, reportFormatter3) + if err != nil { + t.Errorf("Error: %v", err) + } + report4 := "resource \"null_resource\" \"test\" {}" + reportFormatter4 := utils.GetTerraformOutputAsCollapsibleComment("run4") + err = strategy.Report(ciService, prNumber, report4, reportFormatter4) + + if err != nil { + t.Errorf("Error: %v", err) + } + + assert.Equal(t, 2, len(ciService.CommentsPerPr[prNumber])) + assert.Equal(t, "
Digger run report at "+timeOfRun.Format("2006-01-02 15:04:05")+"\n
run1\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n
run2\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n\n
run3\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n\n
run4\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n
", *ciService.CommentsPerPr[prNumber][1].Body) +} + +func TestLatestCommentStrategyReport(t *testing.T) { + timeOfRun := time.Now() + strategy := LatestRunCommentStrategy{ + TimeOfRun: timeOfRun, + } + existingCommentForOtherRun := utils.AsCollapsibleComment("Digger run report at some other time")("") + + prNumber := 1 + ciService := &MockCiService{ + CommentsPerPr: map[int][]*ci.Comment{ + prNumber: { + { + Id: 1, + Body: &existingCommentForOtherRun, + }, + }, + }, + } + + report := "resource \"null_resource\" \"test\" {}" + reportFormatter := utils.GetTerraformOutputAsCollapsibleComment("run1") + err := strategy.Report(ciService, prNumber, report, reportFormatter) + if err != nil { + t.Errorf("Error: %v", err) + } + report2 := "resource \"null_resource\" \"test\" {}" + reportFormatter2 := utils.GetTerraformOutputAsCollapsibleComment("run2") + err = strategy.Report(ciService, prNumber, report2, reportFormatter2) + if err != nil { + t.Errorf("Error: %v", err) + } + report3 := "resource \"null_resource\" \"test\" {}" + reportFormatter3 := utils.GetTerraformOutputAsCollapsibleComment("run3") + err = strategy.Report(ciService, prNumber, report3, reportFormatter3) + if err != nil { + t.Errorf("Error: %v", err) + } + report4 := "resource \"null_resource\" \"test\" {}" + reportFormatter4 := utils.GetTerraformOutputAsCollapsibleComment("run4") + err = strategy.Report(ciService, prNumber, report4, reportFormatter4) + + if err != nil { + t.Errorf("Error: %v", err) + } + + assert.Equal(t, 2, len(ciService.CommentsPerPr[prNumber])) + assert.Equal(t, "
Digger latest run report\n
run1\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n
run2\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n\n
run3\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n\n
run4\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
\n\n
", *ciService.CommentsPerPr[prNumber][1].Body) +} + +func TestMultipleCommentStrategyReport(t *testing.T) { + strategy := MultipleCommentsStrategy{} + existingCommentForOtherRun := utils.AsCollapsibleComment("Digger run report at some other time")("") + + prNumber := 1 + ciService := &MockCiService{ + CommentsPerPr: map[int][]*ci.Comment{ + prNumber: { + { + Id: 1, + Body: &existingCommentForOtherRun, + }, + }, + }, + } + + report := "resource \"null_resource\" \"test\" {}" + reportFormatter := utils.GetTerraformOutputAsCollapsibleComment("run1") + err := strategy.Report(ciService, prNumber, report, reportFormatter) + if err != nil { + t.Errorf("Error: %v", err) + } + report2 := "resource \"null_resource\" \"test\" {}" + reportFormatter2 := utils.GetTerraformOutputAsCollapsibleComment("run2") + err = strategy.Report(ciService, prNumber, report2, reportFormatter2) + if err != nil { + t.Errorf("Error: %v", err) + } + report3 := "resource \"null_resource\" \"test\" {}" + reportFormatter3 := utils.GetTerraformOutputAsCollapsibleComment("run3") + err = strategy.Report(ciService, prNumber, report3, reportFormatter3) + if err != nil { + t.Errorf("Error: %v", err) + } + report4 := "resource \"null_resource\" \"test\" {}" + reportFormatter4 := utils.GetTerraformOutputAsCollapsibleComment("run4") + err = strategy.Report(ciService, prNumber, report4, reportFormatter4) + + if err != nil { + t.Errorf("Error: %v", err) + } + + assert.Equal(t, 5, len(ciService.CommentsPerPr[prNumber])) + assert.Equal(t, "
run4\n \n```terraform\nresource \"null_resource\" \"test\" {}\n ```\n
", *ciService.CommentsPerPr[prNumber][4].Body) +} + +type MockCiService struct { + CommentsPerPr map[int][]*ci.Comment +} + +func (t MockCiService) GetUserTeams(organisation string, user string) ([]string, error) { + return nil, nil +} + +func (t MockCiService) GetChangedFiles(prNumber int) ([]string, error) { + return nil, nil +} +func (t MockCiService) PublishComment(prNumber int, comment string) error { + + latestId := 0 + + for _, comments := range t.CommentsPerPr { + for _, c := range comments { + if c.Id.(int) > latestId { + latestId = c.Id.(int) + } + } + } + + t.CommentsPerPr[prNumber] = append(t.CommentsPerPr[prNumber], &ci.Comment{Id: latestId + 1, Body: &comment}) + + return nil +} + +func (t MockCiService) SetStatus(prNumber int, status string, statusContext string) error { + return nil +} + +func (t MockCiService) GetCombinedPullRequestStatus(prNumber int) (string, error) { + return "", nil +} + +func (t MockCiService) MergePullRequest(prNumber int) error { + return nil +} + +func (t MockCiService) IsMergeable(prNumber int) (bool, error) { + return true, nil +} + +func (t MockCiService) IsMerged(prNumber int) (bool, error) { + return false, nil +} + +func (t MockCiService) DownloadLatestPlans(prNumber int) (string, error) { + return "", nil +} + +func (t MockCiService) IsClosed(prNumber int) (bool, error) { + return false, nil +} + +func (t MockCiService) GetComments(prNumber int) ([]ci.Comment, error) { + comments := []ci.Comment{} + for _, c := range t.CommentsPerPr[prNumber] { + comments = append(comments, *c) + } + return comments, nil +} + +func (t MockCiService) EditComment(commentId interface{}, comment string) error { + for _, comments := range t.CommentsPerPr { + for _, c := range comments { + if c.Id == commentId { + c.Body = &comment + return nil + } + } + } + return nil +} diff --git a/pkg/utils/mocks.go b/pkg/utils/mocks.go index 50366fd70..bc05bd222 100644 --- a/pkg/utils/mocks.go +++ b/pkg/utils/mocks.go @@ -1,5 +1,7 @@ package utils +import "digger/pkg/ci" + type MockTerraform struct { commands []string } @@ -90,6 +92,14 @@ func (t MockPullRequestManager) IsClosed(prNumber int) (bool, error) { return false, nil } +func (t MockPullRequestManager) GetComments(prNumber int) ([]ci.Comment, error) { + return []ci.Comment{}, nil +} + +func (t MockPullRequestManager) EditComment(commentId interface{}, comment string) error { + return nil +} + type MockPlanStorage struct { } diff --git a/pkg/utils/pullrequestmanager_mock.go b/pkg/utils/pullrequestmanager_mock.go index 895c9e3c3..66e09cfb6 100644 --- a/pkg/utils/pullrequestmanager_mock.go +++ b/pkg/utils/pullrequestmanager_mock.go @@ -1,5 +1,16 @@ package utils +import "digger/pkg/ci" + +type MockReporter struct { + commands []string +} + +func (mockReporter *MockReporter) Report(report string, formatter func(string) string) error { + mockReporter.commands = append(mockReporter.commands, "Report") + return nil +} + type MockGithubPullrequestManager struct { commands []string } @@ -52,3 +63,13 @@ func (mockGithubPullrequestManager *MockGithubPullrequestManager) IsMerged(prNum mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "IsClosed") return false, nil } + +func (mockGithubPullrequestManager *MockGithubPullrequestManager) GetComments(prNumber int) ([]ci.Comment, error) { + mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "GetComments") + return []ci.Comment{}, nil +} + +func (mockGithubPullrequestManager *MockGithubPullrequestManager) EditComment(commentId interface{}, comment string) error { + mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "EditComment") + return nil +}