diff --git a/agent-tasks/port-drift-reconciliation-hook-ce.md b/agent-tasks/port-drift-reconciliation-hook-ce.md new file mode 100644 index 000000000..5ea699332 --- /dev/null +++ b/agent-tasks/port-drift-reconciliation-hook-ce.md @@ -0,0 +1,56 @@ +# CE Backend: Port Drift Reconciliation Hook + +## Goal +Enable drift reconciliation in Community Edition: when a user comments "digger apply" or "digger unlock" on a drift Issue (non-PR) created by the drift service, the CE backend should trigger the appropriate jobs and manage locks — parity with EE behavior. + +## Findings +- EE registers `hooks.DriftReconcilliationHook` via `ee/backend/main.go` and implements it in `ee/backend/hooks/github.go`. +- CE exposes `GithubWebhookPostIssueCommentHooks` but does not register a hook; `backend/hooks` has no implementation. As a result, CE ignores drift Issue comments. + +## Scope +- CE functional addition; no behavior change for PR comments. +- Wire the new hook into CE `backend` and dedupe EE to reuse the CE hook in the same change (not optional). + +## Constraints +- Keep implementation as-is: copy the EE hook logic verbatim, only adjusting import/package paths for CE. Do not introduce new logic, behaviors, refactors, or changes to allowed commands. + +## Plan +1) Implement CE hook +- Add `backend/hooks/drift_reconciliation.go` exporting: + - `var DriftReconcilliationHook controllers.IssueCommentHook` +- Copy logic from `ee/backend/hooks/github.go` with CE imports only: `backend/*` and `libs/*`. +- Behavior: + - Only handle IssueComment events on Issues (ignore PR comments). + - Issue title must match `^Drift detected in project:\s*(\S+)` to extract `projectName`. + - Accept commands: `digger apply` and `digger unlock`. + - Lock project, run jobs for the target project on apply, then unlock (mirroring EE flow). + - Post reactions and reporter comments as in EE. + +2) Wire into CE backend +- Update `backend/main.go` to register: + - `GithubWebhookPostIssueCommentHooks: []controllers.IssueCommentHook{hooks.DriftReconcilliationHook}` + +3) Dedupe EE to reuse CE hook +- Switch `ee/backend/main.go` to import `github.com/diggerhq/digger/backend/hooks` and remove EE-local hook implementation. + +4) Verification +- Build: `go build ./backend` and `go build ./ee/backend`. +- Manual: Comment `digger apply` on a generated drift Issue; verify locks and jobs are triggered; `digger unlock` removes locks. + +## Acceptance Criteria +- CE backend reacts to drift Issue comments (not PRs) with title pattern above. +- `digger apply` triggers jobs for the extracted project and unlocks afterward. +- `digger unlock` removes locks and acknowledges success. +- No regressions to PR comment handling; existing PR workflows remain unchanged. +- `go build ./backend`, `go build ./ee/backend` succeed. + +## Tasks Checklist +- [ ] Add `backend/hooks/drift_reconciliation.go` with CE implementation. +- [ ] Register hook in `backend/main.go`. +- [ ] Build CE and EE backends. +- [ ] Point EE to CE hook and delete EE duplicate. +- [ ] Smoke-test via Issue comments. + +## Notes +- Hook name keeps EE spelling (`DriftReconcilliationHook`) for parity; consider a later rename only if safe. +- Keep allowed commands restricted to `digger apply` and `digger unlock` for drift Issues. diff --git a/backend/hooks/drift_reconciliation.go b/backend/hooks/drift_reconciliation.go new file mode 100644 index 000000000..72b7a3d80 --- /dev/null +++ b/backend/hooks/drift_reconciliation.go @@ -0,0 +1,198 @@ +package hooks + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" + + "github.com/diggerhq/digger/backend/ci_backends" + controllers "github.com/diggerhq/digger/backend/controllers" + "github.com/diggerhq/digger/backend/locking" + "github.com/diggerhq/digger/backend/models" + "github.com/diggerhq/digger/backend/utils" + "github.com/diggerhq/digger/libs/ci/generic" + dg_github "github.com/diggerhq/digger/libs/ci/github" + comment_updater "github.com/diggerhq/digger/libs/comment_utils/reporting" + "github.com/diggerhq/digger/libs/digger_config" + dg_locking "github.com/diggerhq/digger/libs/locking" + "github.com/diggerhq/digger/libs/scheduler" + "github.com/google/go-github/v61/github" + "github.com/samber/lo" +) + +// DriftReconcilliationHook handles drift Issue comments (not PRs) allowing +// "digger apply" or "digger unlock" on issues with titles like +// "Drift detected in project: ". +// +// Implementation is verbatim from EE, with CE imports. +var DriftReconcilliationHook controllers.IssueCommentHook = func(gh utils.GithubClientProvider, payload *github.IssueCommentEvent, ciBackendProvider ci_backends.CiBackendProvider) error { + log.Printf("handling the drift reconcilliation hook") + installationId := *payload.Installation.ID + repoName := *payload.Repo.Name + repoOwner := *payload.Repo.Owner.Login + repoFullName := *payload.Repo.FullName + cloneURL := *payload.Repo.CloneURL + issueTitle := *payload.Issue.Title + issueNumber := *payload.Issue.Number + userCommentId := *payload.GetComment().ID + actor := *payload.Sender.Login + commentBody := *payload.Comment.Body + defaultBranch := *payload.Repo.DefaultBranch + isPullRequest := payload.Issue.IsPullRequest() + + if isPullRequest { + log.Printf("Comment is not an issue, ignoring") + return nil + } + + // checking that the title of the issue matches regex + var projectName string + re := regexp.MustCompile(`^Drift detected in project:\s*(\S+)`) + matches := re.FindStringSubmatch(issueTitle) + if len(matches) > 1 { + projectName = matches[1] + } else { + log.Printf("does not look like a drift issue, ignoring") + } + + link, err := models.DB.GetGithubAppInstallationLink(installationId) + if err != nil { + log.Printf("Error getting GetGithubAppInstallationLink: %v", err) + return fmt.Errorf("error getting github app link") + } + orgId := link.OrganisationId + + if *payload.Action != "created" { + log.Printf("comment is not of type 'created', ignoring") + return nil + } + + allowedCommands := []string{"digger apply", "digger unlock"} + if !lo.Contains(allowedCommands, strings.TrimSpace(*payload.Comment.Body)) { + log.Printf("comment is not in allowed commands, ignoring") + log.Printf("allowed commands: %v", allowedCommands) + return nil + } + + diggerYmlStr, ghService, config, projectsGraph, err := controllers.GetDiggerConfigForBranch(gh, installationId, repoFullName, repoOwner, repoName, cloneURL, defaultBranch, nil, nil) + if err != nil { + log.Printf("Error loading digger.yml: %v", err) + return fmt.Errorf("error loading digger.yml") + } + + commentIdStr := strconv.FormatInt(userCommentId, 10) + err = ghService.CreateCommentReaction(commentIdStr, string(dg_github.GithubCommentEyesReaction)) + if err != nil { + log.Printf("CreateCommentReaction error: %v", err) + } + + diggerCommand, err := scheduler.GetCommandFromComment(*payload.Comment.Body) + if err != nil { + log.Printf("unknown digger command in comment: %v", *payload.Comment.Body) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Could not recognise comment, error: %v", err)) + return fmt.Errorf("unknown digger command in comment %v", err) + } + + // attempting to lock for performing drift apply command + prLock := dg_locking.PullRequestLock{ + InternalLock: locking.BackendDBLock{ + OrgId: orgId, + }, + CIService: ghService, + Reporter: comment_updater.NoopReporter{}, + ProjectName: projectName, + ProjectNamespace: repoFullName, + PrNumber: issueNumber, + } + err = dg_locking.PerformLockingActionFromCommand(prLock, *diggerCommand) + if err != nil { + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Failed perform lock action on project: %v %v", projectName, err)) + return fmt.Errorf("failed perform lock action on project: %v %v", projectName, err) + } + + if *diggerCommand == scheduler.DiggerCommandUnlock { + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":white_check_mark: Command %v completed successfully", *diggerCommand)) + return nil + } + + // === if we get here its a "digger apply command and we are already locked for this project ==== + // perform apply here then unlock the project + commentReporter, err := utils.InitCommentReporter(ghService, issueNumber, ":construction_worker: Digger starting....") + if err != nil { + log.Printf("Error initializing comment reporter: %v", err) + return fmt.Errorf("error initializing comment reporter") + } + + impactedProjects := config.GetProjects(projectName) + jobs, coverAllImpactedProjects, err := generic.ConvertIssueCommentEventToJobs(repoFullName, actor, issueNumber, commentBody, impactedProjects, nil, config.Workflows, defaultBranch, defaultBranch, false) + if err != nil { + log.Printf("Error converting event to jobs: %v", err) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Error converting event to jobs: %v", err)) + return fmt.Errorf("error converting event to jobs") + } + log.Printf("GitHub IssueComment event converted to Jobs successfully\n") + + err = utils.ReportInitialJobsStatus(commentReporter, jobs) + if err != nil { + log.Printf("Failed to comment initial status for jobs: %v", err) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Failed to comment initial status for jobs: %v", err)) + return fmt.Errorf("failed to comment initial status for jobs") + } + + impactedProjectsMap := make(map[string]digger_config.Project) + for _, p := range impactedProjects { + impactedProjectsMap[p.Name] = p + } + + impactedProjectsJobMap := make(map[string]scheduler.Job) + for _, j := range jobs { + impactedProjectsJobMap[j.ProjectName] = j + } + + reporterCommentId, err := strconv.ParseInt(commentReporter.CommentId, 10, 64) + if err != nil { + log.Printf("strconv.ParseInt error: %v", err) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: could not handle commentId: %v", err)) + } + + batchId, _, err := utils.ConvertJobsToDiggerJobs(*diggerCommand, "github", orgId, impactedProjectsJobMap, impactedProjectsMap, projectsGraph, installationId, defaultBranch, issueNumber, repoOwner, repoName, repoFullName, "", reporterCommentId, diggerYmlStr, 0, "", false, coverAllImpactedProjects, nil) + if err != nil { + log.Printf("ConvertJobsToDiggerJobs error: %v", err) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: ConvertJobsToDiggerJobs error: %v", err)) + return fmt.Errorf("error convertingjobs") + } + + ciBackend, err := ciBackendProvider.GetCiBackend( + ci_backends.CiBackendOptions{ + GithubClientProvider: gh, + GithubInstallationId: installationId, + RepoName: repoName, + RepoOwner: repoOwner, + RepoFullName: repoFullName, + }, + ) + if err != nil { + log.Printf("GetCiBackend error: %v", err) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: GetCiBackend error: %v", err)) + return fmt.Errorf("error fetching ci backed %v", err) + } + + err = controllers.TriggerDiggerJobs(ciBackend, repoFullName, repoOwner, repoName, batchId, issueNumber, ghService, gh) + if err != nil { + log.Printf("TriggerDiggerJobs error: %v", err) + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: TriggerDiggerJobs error: %v", err)) + return fmt.Errorf("error triggering Digger Jobs") + } + + // === now unlocking the project === + err = dg_locking.PerformLockingActionFromCommand(prLock, scheduler.DiggerCommandUnlock) + if err != nil { + utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Failed perform lock action on project: %v %v", projectName, err)) + return fmt.Errorf("failed perform lock action on project: %v %v", projectName, err) + } + + return nil +} + diff --git a/backend/main.go b/backend/main.go index 4c3baaf45..e9ca3f399 100644 --- a/backend/main.go +++ b/backend/main.go @@ -7,6 +7,7 @@ import ( "github.com/diggerhq/digger/backend/ci_backends" "github.com/diggerhq/digger/backend/config" "github.com/diggerhq/digger/backend/controllers" + "github.com/diggerhq/digger/backend/hooks" "github.com/diggerhq/digger/backend/utils" ) @@ -17,7 +18,7 @@ func main() { ghController := controllers.DiggerController{ CiBackendProvider: ci_backends.DefaultBackendProvider{}, GithubClientProvider: utils.DiggerGithubRealClientProvider{}, - GithubWebhookPostIssueCommentHooks: make([]controllers.IssueCommentHook, 0), + GithubWebhookPostIssueCommentHooks: []controllers.IssueCommentHook{hooks.DriftReconcilliationHook}, } r := bootstrap.Bootstrap(templates, ghController) r.GET("/", controllers.Home) diff --git a/ee/backend/hooks/github.go b/ee/backend/hooks/github.go deleted file mode 100644 index bbbbb1b5e..000000000 --- a/ee/backend/hooks/github.go +++ /dev/null @@ -1,192 +0,0 @@ -package hooks - -import ( - "fmt" - "log" - "regexp" - "strconv" - "strings" - - "github.com/diggerhq/digger/backend/ci_backends" - ce_controllers "github.com/diggerhq/digger/backend/controllers" - "github.com/diggerhq/digger/backend/locking" - "github.com/diggerhq/digger/backend/models" - "github.com/diggerhq/digger/backend/utils" - "github.com/diggerhq/digger/libs/ci/generic" - dg_github "github.com/diggerhq/digger/libs/ci/github" - comment_updater "github.com/diggerhq/digger/libs/comment_utils/reporting" - "github.com/diggerhq/digger/libs/digger_config" - dg_locking "github.com/diggerhq/digger/libs/locking" - "github.com/diggerhq/digger/libs/scheduler" - "github.com/google/go-github/v61/github" - "github.com/samber/lo" -) - -var DriftReconcilliationHook ce_controllers.IssueCommentHook = func(gh utils.GithubClientProvider, payload *github.IssueCommentEvent, ciBackendProvider ci_backends.CiBackendProvider) error { - log.Printf("handling the drift reconcilliation hook") - installationId := *payload.Installation.ID - repoName := *payload.Repo.Name - repoOwner := *payload.Repo.Owner.Login - repoFullName := *payload.Repo.FullName - cloneURL := *payload.Repo.CloneURL - issueTitle := *payload.Issue.Title - issueNumber := *payload.Issue.Number - userCommentId := *payload.GetComment().ID - actor := *payload.Sender.Login - commentBody := *payload.Comment.Body - defaultBranch := *payload.Repo.DefaultBranch - isPullRequest := payload.Issue.IsPullRequest() - - if isPullRequest { - log.Printf("Comment is not an issue, ignoring") - return nil - } - - // checking that the title of the issue matches regex - var projectName string - re := regexp.MustCompile(`^Drift detected in project:\s*(\S+)`) - matches := re.FindStringSubmatch(issueTitle) - if len(matches) > 1 { - projectName = matches[1] - } else { - log.Printf("does not look like a drift issue, ignoring") - } - - link, err := models.DB.GetGithubAppInstallationLink(installationId) - if err != nil { - log.Printf("Error getting GetGithubAppInstallationLink: %v", err) - return fmt.Errorf("error getting github app link") - } - orgId := link.OrganisationId - - if *payload.Action != "created" { - log.Printf("comment is not of type 'created', ignoring") - return nil - } - - allowedCommands := []string{"digger apply", "digger unlock"} - if !lo.Contains(allowedCommands, strings.TrimSpace(*payload.Comment.Body)) { - log.Printf("comment is not in allowed commands, ignoring") - log.Printf("allowed commands: %v", allowedCommands) - return nil - } - - diggerYmlStr, ghService, config, projectsGraph, err := ce_controllers.GetDiggerConfigForBranch(gh, installationId, repoFullName, repoOwner, repoName, cloneURL, defaultBranch, nil, nil) - if err != nil { - log.Printf("Error loading digger.yml: %v", err) - return fmt.Errorf("error loading digger.yml") - } - - commentIdStr := strconv.FormatInt(userCommentId, 10) - err = ghService.CreateCommentReaction(commentIdStr, string(dg_github.GithubCommentEyesReaction)) - if err != nil { - log.Printf("CreateCommentReaction error: %v", err) - } - - diggerCommand, err := scheduler.GetCommandFromComment(*payload.Comment.Body) - if err != nil { - log.Printf("unknown digger command in comment: %v", *payload.Comment.Body) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Could not recognise comment, error: %v", err)) - return fmt.Errorf("unknown digger command in comment %v", err) - } - - // attempting to lock for performing drift apply command - prLock := dg_locking.PullRequestLock{ - InternalLock: locking.BackendDBLock{ - OrgId: orgId, - }, - CIService: ghService, - Reporter: comment_updater.NoopReporter{}, - ProjectName: projectName, - ProjectNamespace: repoFullName, - PrNumber: issueNumber, - } - err = dg_locking.PerformLockingActionFromCommand(prLock, *diggerCommand) - if err != nil { - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Failed perform lock action on project: %v %v", projectName, err)) - return fmt.Errorf("failed perform lock action on project: %v %v", projectName, err) - } - - if *diggerCommand == scheduler.DiggerCommandUnlock { - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":white_check_mark: Command %v completed successfully", *diggerCommand)) - return nil - } - - // === if we get here its a "digger apply command and we are already locked for this project ==== - // perform apply here then unlock the project - commentReporter, err := utils.InitCommentReporter(ghService, issueNumber, ":construction_worker: Digger starting....") - if err != nil { - log.Printf("Error initializing comment reporter: %v", err) - return fmt.Errorf("error initializing comment reporter") - } - - impactedProjects := config.GetProjects(projectName) - jobs, coverAllImpactedProjects, err := generic.ConvertIssueCommentEventToJobs(repoFullName, actor, issueNumber, commentBody, impactedProjects, nil, config.Workflows, defaultBranch, defaultBranch, false) - if err != nil { - log.Printf("Error converting event to jobs: %v", err) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Error converting event to jobs: %v", err)) - return fmt.Errorf("error converting event to jobs") - } - log.Printf("GitHub IssueComment event converted to Jobs successfully\n") - - err = utils.ReportInitialJobsStatus(commentReporter, jobs) - if err != nil { - log.Printf("Failed to comment initial status for jobs: %v", err) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Failed to comment initial status for jobs: %v", err)) - return fmt.Errorf("failed to comment initial status for jobs") - } - - impactedProjectsMap := make(map[string]digger_config.Project) - for _, p := range impactedProjects { - impactedProjectsMap[p.Name] = p - } - - impactedProjectsJobMap := make(map[string]scheduler.Job) - for _, j := range jobs { - impactedProjectsJobMap[j.ProjectName] = j - } - - reporterCommentId, err := strconv.ParseInt(commentReporter.CommentId, 10, 64) - if err != nil { - log.Printf("strconv.ParseInt error: %v", err) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: could not handle commentId: %v", err)) - } - - batchId, _, err := utils.ConvertJobsToDiggerJobs(*diggerCommand, "github", orgId, impactedProjectsJobMap, impactedProjectsMap, projectsGraph, installationId, defaultBranch, issueNumber, repoOwner, repoName, repoFullName, "", reporterCommentId, diggerYmlStr, 0, "", false, coverAllImpactedProjects, nil) - if err != nil { - log.Printf("ConvertJobsToDiggerJobs error: %v", err) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: ConvertJobsToDiggerJobs error: %v", err)) - return fmt.Errorf("error convertingjobs") - } - - ciBackend, err := ciBackendProvider.GetCiBackend( - ci_backends.CiBackendOptions{ - GithubClientProvider: gh, - GithubInstallationId: installationId, - RepoName: repoName, - RepoOwner: repoOwner, - RepoFullName: repoFullName, - }, - ) - if err != nil { - log.Printf("GetCiBackend error: %v", err) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: GetCiBackend error: %v", err)) - return fmt.Errorf("error fetching ci backed %v", err) - } - - err = ce_controllers.TriggerDiggerJobs(ciBackend, repoFullName, repoOwner, repoName, batchId, issueNumber, ghService, gh) - if err != nil { - log.Printf("TriggerDiggerJobs error: %v", err) - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: TriggerDiggerJobs error: %v", err)) - return fmt.Errorf("error triggering Digger Jobs") - } - - // === now unlocking the project === - err = dg_locking.PerformLockingActionFromCommand(prLock, scheduler.DiggerCommandUnlock) - if err != nil { - utils.InitCommentReporter(ghService, issueNumber, fmt.Sprintf(":x: Failed perform lock action on project: %v %v", projectName, err)) - return fmt.Errorf("failed perform lock action on project: %v %v", projectName, err) - } - - return nil -} diff --git a/ee/backend/main.go b/ee/backend/main.go index 64267c8cd..8ed04566c 100644 --- a/ee/backend/main.go +++ b/ee/backend/main.go @@ -7,11 +7,11 @@ import ( "github.com/diggerhq/digger/backend/bootstrap" "github.com/diggerhq/digger/backend/config" ce_controllers "github.com/diggerhq/digger/backend/controllers" + "github.com/diggerhq/digger/backend/hooks" "github.com/diggerhq/digger/backend/middleware" "github.com/diggerhq/digger/backend/utils" ci_backends2 "github.com/diggerhq/digger/ee/backend/ci_backends" "github.com/diggerhq/digger/ee/backend/controllers" - "github.com/diggerhq/digger/ee/backend/hooks" "github.com/diggerhq/digger/ee/backend/providers/github" "github.com/diggerhq/digger/libs/license" "github.com/gin-gonic/gin"