Skip to content

Commit 04b2fe8

Browse files
authored
Switch from callbacks to webhooks for repos (#2466)
* Agent task to move from callback to webhooks for repo syncing * Move from callback to webhook for repos * Add docs for local development * Docs: UI as gateway for local dev; github app * Not require code in callback * Add retry logic to installation link fetching
1 parent d19c1ea commit 04b2fe8

File tree

15 files changed

+602
-196
lines changed

15 files changed

+602
-196
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# GitHub repo sync via webhooks
2+
3+
## Goals
4+
- Move repo sync away from the OAuth callback and drive updates from GitHub webhooks.
5+
- Keep Digger’s repo list accurate when repos are added/removed from the app scope.
6+
- On uninstall, soft-delete repos (and their installation records) so they disappear from the UI/API.
7+
8+
## Current behavior (source of truth today)
9+
- OAuth callback (`backend/controllers/github_callback.go`) validates the install, links/creates org, then lists all repos via `Apps.ListRepos`, soft-deletes existing `github_app_installations` and repos for the org, and recreates them via `GithubRepoAdded` + `createOrGetDiggerRepoForGithubRepo`.
10+
- Webhook handler (`backend/controllers/github.go`) only uses `installation` events with action `deleted` to mark installation links inactive and set `github_app_installations` status deleted for the repos in the payload. It does not touch `repos`. There is no handling for `installation_repositories` add/remove.
11+
- Runtime lookups (`GetGithubService` / `GetGithubClient`) require an active record in `github_app_installations` for the repo.
12+
13+
## Target design
14+
- Keep OAuth callback minimal: verify installation, create/link org, store the install id/app id, but do **not** list or mutate repos. It should return immediately and rely on webhooks for repo population.
15+
- Webhook-driven reconciliation:
16+
- `installation` event (`created`, `unsuspended`, `new_permissions_accepted`): ensure installation link exists/active; reconcile repos using the payload’s `installation.repositories` list as authoritative. If the link is missing, log an error and return (no auto-create).
17+
- Soft-delete existing `github_app_installations` for that installation id, and soft-delete repos for the linked org (scoped to that installation) before re-adding.
18+
- Upsert each repo: mark/install via `GithubRepoAdded` and create/restore the Digger repo record (store app id, installation id, default branch, clone URL when available).
19+
- `installation_repositories` event: incrementally apply scope changes.
20+
- For `repositories_added`: fetch repo details (to get default branch + clone URL), then call `GithubRepoAdded` and create/restore the repo record.
21+
- For `repositories_removed`: mark `GithubRepoRemoved`, soft-delete the repo **and its projects**, and handle absence gracefully.
22+
- `installation` event (`deleted`): mark installation link inactive, mark installation records deleted, and soft-delete repos **and projects** for that installation’s org so they no longer appear in APIs/UI.
23+
- Shared helpers:
24+
- `syncReposForInstallation(installationId, appId, reposPayload)` to wrap the add/remove logic and reuse between `installation` and `installation_repositories` handlers.
25+
- `softDeleteRepoAndProjects(orgId, repoFullName)` to encapsulate repo + project soft-deletion.
26+
- Observability: structured logs per action, and possibly a metric for sync success/failure per installation.
27+
28+
## Migration plan
29+
1) Add webhook handling for `installation_repositories` in `GithubAppWebHook` switch and wire to a new handler.
30+
2) Extend `installation` handling to cover `created`/`unsuspended` (not just `deleted`) and call `syncReposForInstallation`.
31+
3) Update uninstall handling to also soft-delete repos and projects.
32+
4) Strip repo enumeration/deletion from the OAuth callback; leave only installation/org linking.
33+
5) Add tests using existing payload fixtures (`installationRepositoriesAddedPayload`, `installationRepositoriesDeletedPayload`, `installationCreatedEvent`) to verify DB state changes (installation records + repos soft-delete/restore).
34+
6) Backfill existing installations: one-off job/command or admin endpoint to resync repos via `Apps.ListRepos` and `syncReposForInstallation` to align data after deploying (manual trigger, no cron yet).
35+
36+
## Testing / validation
37+
- Unit tests for add/remove/uninstall flows verifying:
38+
- `github_app_installations` status transitions.
39+
- Repos are created/restored with correct installation/app ids.
40+
- Repos and projects are soft-deleted on removal/uninstall.
41+
42+
## Open questions
43+
- None right now (decided: log missing-link errors only; soft-delete repos and projects on removal/uninstall; add manual resync endpoint, no cron yet).

backend/bootstrap/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ func Bootstrap(templates embed.FS, diggerController controllers.DiggerController
242242

243243
githubApiGroup := apiGroup.Group("/github")
244244
githubApiGroup.POST("/link", controllers.LinkGithubInstallationToOrgApi)
245+
githubApiGroup.POST("/resync", controllers.ResyncGithubInstallationApi)
245246

246247
vcsApiGroup := apiGroup.Group("/connections")
247248
vcsApiGroup.GET("/:id", controllers.GetVCSConnection)

backend/controllers/github.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ func (d DiggerController) GithubAppWebHook(c *gin.Context) {
7373
c.String(http.StatusAccepted, "Failed to handle webhook event.")
7474
return
7575
}
76+
} else if *event.Action == "created" || *event.Action == "unsuspended" || *event.Action == "new_permissions_accepted" {
77+
if err := handleInstallationUpsertEvent(c.Request.Context(), gh, event, appId64); err != nil {
78+
slog.Error("Failed to handle installation upsert event", "error", err)
79+
c.String(http.StatusAccepted, "Failed to handle webhook event.")
80+
return
81+
}
82+
}
83+
case *github.InstallationRepositoriesEvent:
84+
slog.Info("Processing InstallationRepositoriesEvent",
85+
"action", event.GetAction(),
86+
"installationId", event.Installation.GetID(),
87+
"added", len(event.RepositoriesAdded),
88+
"removed", len(event.RepositoriesRemoved),
89+
)
90+
if err := handleInstallationRepositoriesEvent(c.Request.Context(), gh, event, appId64); err != nil {
91+
slog.Error("Failed to handle installation repositories event", "error", err)
92+
c.String(http.StatusAccepted, "Failed to handle webhook event.")
93+
return
7694
}
7795
case *github.PushEvent:
7896
slog.Info("Processing PushEvent",

backend/controllers/github_api.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import (
88

99
"github.com/diggerhq/digger/backend/middleware"
1010
"github.com/diggerhq/digger/backend/models"
11+
"github.com/diggerhq/digger/backend/utils"
12+
ci_github "github.com/diggerhq/digger/libs/ci/github"
1113
"github.com/gin-gonic/gin"
14+
"github.com/google/go-github/v61/github"
1215
"gorm.io/gorm"
1316
)
1417

@@ -85,3 +88,82 @@ func LinkGithubInstallationToOrgApi(c *gin.Context) {
8588
c.JSON(http.StatusOK, gin.H{"status": "Successfully created Github installation link"})
8689
return
8790
}
91+
92+
func ResyncGithubInstallationApi(c *gin.Context) {
93+
type ResyncInstallationRequest struct {
94+
InstallationId string `json:"installation_id"`
95+
}
96+
97+
var request ResyncInstallationRequest
98+
if err := c.BindJSON(&request); err != nil {
99+
slog.Error("Error binding JSON for resync", "error", err)
100+
c.JSON(http.StatusBadRequest, gin.H{"status": "Invalid request format"})
101+
return
102+
}
103+
104+
installationId, err := strconv.ParseInt(request.InstallationId, 10, 64)
105+
if err != nil {
106+
slog.Error("Failed to convert InstallationId to int64", "installationId", request.InstallationId, "error", err)
107+
c.JSON(http.StatusBadRequest, gin.H{"status": "installationID should be a valid integer"})
108+
return
109+
}
110+
111+
link, err := models.DB.GetGithubAppInstallationLink(installationId)
112+
if err != nil {
113+
slog.Error("Could not get installation link for resync", "installationId", installationId, "error", err)
114+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Could not get installation link"})
115+
return
116+
}
117+
if link == nil {
118+
slog.Warn("Installation link not found for resync", "installationId", installationId)
119+
c.JSON(http.StatusNotFound, gin.H{"status": "Installation link not found"})
120+
return
121+
}
122+
123+
var installationRecord models.GithubAppInstallation
124+
if err := models.DB.GormDB.Where("github_installation_id = ?", installationId).Order("updated_at desc").First(&installationRecord).Error; err != nil {
125+
if errors.Is(err, gorm.ErrRecordNotFound) {
126+
slog.Warn("No installation records found for resync", "installationId", installationId)
127+
c.JSON(http.StatusNotFound, gin.H{"status": "No installation records found"})
128+
return
129+
}
130+
slog.Error("Failed to fetch installation record for resync", "installationId", installationId, "error", err)
131+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Could not fetch installation records"})
132+
return
133+
}
134+
135+
appId := installationRecord.GithubAppId
136+
ghProvider := utils.DiggerGithubRealClientProvider{}
137+
138+
client, _, err := ghProvider.Get(appId, installationId)
139+
if err != nil {
140+
slog.Error("Failed to create GitHub client for resync", "installationId", installationId, "appId", appId, "error", err)
141+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Failed to create GitHub client"})
142+
return
143+
}
144+
145+
repos, err := ci_github.ListGithubRepos(client)
146+
if err != nil {
147+
slog.Error("Failed to list repos for resync", "installationId", installationId, "error", err)
148+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Failed to list repos for resync"})
149+
return
150+
}
151+
152+
installationPayload := &github.Installation{
153+
ID: github.Int64(installationId),
154+
AppID: github.Int64(appId),
155+
}
156+
resyncEvent := &github.InstallationEvent{
157+
Installation: installationPayload,
158+
Repositories: repos,
159+
}
160+
161+
if err := handleInstallationUpsertEvent(c.Request.Context(), ghProvider, resyncEvent, appId); err != nil {
162+
slog.Error("Resync failed", "installationId", installationId, "error", err)
163+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Resync failed"})
164+
return
165+
}
166+
167+
slog.Info("Resync completed", "installationId", installationId, "repoCount", len(repos))
168+
c.JSON(http.StatusOK, gin.H{"status": "Resync completed", "repoCount": len(repos)})
169+
}

0 commit comments

Comments
 (0)