Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions agent-tasks/port-drift-reconciliation-hook-ce.md
Original file line number Diff line number Diff line change
@@ -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.
198 changes: 198 additions & 0 deletions backend/hooks/drift_reconciliation.go
Original file line number Diff line number Diff line change
@@ -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: <projectName>".
//
// 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
}

3 changes: 2 additions & 1 deletion backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
Expand Down
Loading
Loading