Skip to content

Commit dac93a9

Browse files
authored
Move from callbacks to webhooks for repos handling - again - on top of v0.6.136 this time (#2492)
* Bring back docs on local development * Improve local dev docs * Agent task for moving from callbacks to webhooks * Move from callback to webhooks, again
1 parent 72873a9 commit dac93a9

File tree

12 files changed

+1154
-180
lines changed

12 files changed

+1154
-180
lines changed

agent-tasks/webhook-based-repo-management.md

Lines changed: 605 additions & 0 deletions
Large diffs are not rendered by default.

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: 84 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,84 @@ 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+
// Get appId from an existing installation record
124+
var installationRecord models.GithubAppInstallation
125+
if err := models.DB.GormDB.Where("github_installation_id = ?", installationId).Order("updated_at desc").First(&installationRecord).Error; err != nil {
126+
if errors.Is(err, gorm.ErrRecordNotFound) {
127+
slog.Warn("No installation records found for resync", "installationId", installationId)
128+
c.JSON(http.StatusNotFound, gin.H{"status": "No installation records found"})
129+
return
130+
}
131+
slog.Error("Failed to fetch installation record for resync", "installationId", installationId, "error", err)
132+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Could not fetch installation records"})
133+
return
134+
}
135+
136+
appId := installationRecord.GithubAppId
137+
ghProvider := utils.DiggerGithubRealClientProvider{}
138+
139+
client, _, err := ghProvider.Get(appId, installationId)
140+
if err != nil {
141+
slog.Error("Failed to create GitHub client for resync", "installationId", installationId, "appId", appId, "error", err)
142+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Failed to create GitHub client"})
143+
return
144+
}
145+
146+
repos, err := ci_github.ListGithubRepos(client)
147+
if err != nil {
148+
slog.Error("Failed to list repos for resync", "installationId", installationId, "error", err)
149+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Failed to list repos for resync"})
150+
return
151+
}
152+
153+
// Build synthetic InstallationEvent and call upsert handler
154+
installationPayload := &github.Installation{
155+
ID: github.Int64(installationId),
156+
AppID: github.Int64(appId),
157+
}
158+
resyncEvent := &github.InstallationEvent{
159+
Installation: installationPayload,
160+
Repositories: repos,
161+
}
162+
163+
if err := handleInstallationUpsertEvent(c.Request.Context(), ghProvider, resyncEvent, appId); err != nil {
164+
slog.Error("Resync failed", "installationId", installationId, "error", err)
165+
c.JSON(http.StatusInternalServerError, gin.H{"status": "Resync failed"})
166+
return
167+
}
168+
169+
slog.Info("Resync completed", "installationId", installationId, "repoCount", len(repos))
170+
c.JSON(http.StatusOK, gin.H{"status": "Resync completed", "repoCount": len(repos)})
171+
}

backend/controllers/github_callback.go

Lines changed: 42 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,9 @@ import (
55
"log/slog"
66
"net/http"
77
"strconv"
8-
"strings"
98

109
"github.com/diggerhq/digger/backend/models"
1110
"github.com/diggerhq/digger/backend/segment"
12-
"github.com/diggerhq/digger/backend/utils"
13-
"github.com/diggerhq/digger/libs/ci/github"
1411
"github.com/gin-gonic/gin"
1512
"github.com/google/uuid"
1613
)
@@ -28,29 +25,17 @@ func (d DiggerController) GithubAppCallbackPage(c *gin.Context) {
2825
c.String(http.StatusBadRequest, "installation_id parameter for github app is empty")
2926
return
3027
}
31-
//setupAction := c.Request.URL.Query()["setup_action"][0]
28+
29+
// Code parameter is optional - GitHub doesn't always send it on re-authorization flows
3230
codeParams, codeExists := c.Request.URL.Query()["code"]
33-
if !codeExists || len(codeParams) == 0 {
34-
slog.Error("There was no code in the url query parameters")
35-
c.String(http.StatusBadRequest, "could not find the code query parameter for github app")
36-
return
37-
}
38-
code := codeParams[0]
39-
if len(code) < 1 {
40-
slog.Error("Code parameter is empty")
41-
c.String(http.StatusBadRequest, "code parameter for github app is empty")
42-
return
31+
code := ""
32+
if codeExists && len(codeParams) > 0 && len(codeParams[0]) > 0 {
33+
code = codeParams[0]
4334
}
44-
appId := c.Request.URL.Query().Get("state")
4535

46-
slog.Info("Processing GitHub app callback", "installationId", installationId, "appId", appId)
36+
appId := c.Request.URL.Query().Get("state")
4737

48-
clientId, clientSecret, _, _, err := d.GithubClientProvider.FetchCredentials(appId)
49-
if err != nil {
50-
slog.Error("Could not fetch credentials for GitHub app", "appId", appId, "error", err)
51-
c.String(http.StatusInternalServerError, "could not find credentials for github app")
52-
return
53-
}
38+
slog.Info("Processing GitHub app callback", "installationId", installationId, "appId", appId, "hasCode", code != "")
5439

5540
installationId64, err := strconv.ParseInt(installationId, 10, 64)
5641
if err != nil {
@@ -62,38 +47,47 @@ func (d DiggerController) GithubAppCallbackPage(c *gin.Context) {
6247
return
6348
}
6449

65-
slog.Debug("Validating GitHub callback", "installationId", installationId64, "clientId", clientId)
50+
// vcsOwner is used for analytics; we'll populate it if we can validate via OAuth
51+
var vcsOwner string
6652

67-
result, installation, err := validateGithubCallback(d.GithubClientProvider, clientId, clientSecret, code, installationId64)
68-
if !result {
69-
slog.Error("Failed to validate installation ID",
70-
"installationId", installationId64,
71-
"error", err,
72-
)
73-
c.String(http.StatusInternalServerError, "Failed to validate installation_id.")
74-
return
75-
}
53+
// If we have a code parameter, validate the callback via OAuth
54+
// This provides additional security by confirming the user authorized the installation
55+
if code != "" {
56+
clientId, clientSecret, _, _, err := d.GithubClientProvider.FetchCredentials(appId)
57+
if err != nil {
58+
slog.Error("Could not fetch credentials for GitHub app", "appId", appId, "error", err)
59+
c.String(http.StatusInternalServerError, "could not find credentials for github app")
60+
return
61+
}
7662

77-
// TODO: Lookup org in GithubAppInstallation by installationID if found use that installationID otherwise
78-
// create a new org for this installationID
79-
// retrieve org for current orgID
80-
installationIdInt64, err := strconv.ParseInt(installationId, 10, 64)
81-
if err != nil {
82-
slog.Error("Failed to parse installation ID as int64",
83-
"installationId", installationId,
84-
"error", err,
63+
slog.Debug("Validating GitHub callback", "installationId", installationId64, "clientId", clientId)
64+
65+
result, installation, err := validateGithubCallback(d.GithubClientProvider, clientId, clientSecret, code, installationId64)
66+
if !result {
67+
slog.Error("Failed to validate installation ID",
68+
"installationId", installationId64,
69+
"error", err,
70+
)
71+
c.String(http.StatusInternalServerError, "Failed to validate installation_id.")
72+
return
73+
}
74+
75+
if installation != nil && installation.Account != nil && installation.Account.Login != nil {
76+
vcsOwner = *installation.Account.Login
77+
}
78+
} else {
79+
slog.Info("No code parameter provided, skipping OAuth validation (repos will sync via webhook)",
80+
"installationId", installationId64,
8581
)
86-
c.JSON(http.StatusInternalServerError, gin.H{"error": "installationId could not be parsed"})
87-
return
8882
}
8983

90-
slog.Debug("Looking up GitHub app installation link", "installationId", installationIdInt64)
84+
slog.Debug("Looking up GitHub app installation link", "installationId", installationId64)
9185

9286
var link *models.GithubAppInstallationLink
93-
link, err = models.DB.GetGithubAppInstallationLink(installationIdInt64)
87+
link, err = models.DB.GetGithubAppInstallationLink(installationId64)
9488
if err != nil {
9589
slog.Error("Error getting GitHub app installation link",
96-
"installationId", installationIdInt64,
90+
"installationId", installationId64,
9791
"error", err,
9892
)
9993
c.JSON(http.StatusInternalServerError, gin.H{"error": "error getting github app link"})
@@ -153,14 +147,10 @@ func (d DiggerController) GithubAppCallbackPage(c *gin.Context) {
153147
org := link.Organisation
154148
orgId := link.OrganisationId
155149

156-
var vcsOwner string = ""
157-
if installation.Account.Login != nil {
158-
vcsOwner = *installation.Account.Login
159-
}
160-
// we have multiple repos here, we don't really want to send an track event for each repo, so we just send the vcs owner
150+
// Track the installation event for analytics
161151
segment.Track(*org, vcsOwner, "", "github", "vcs_repo_installed", map[string]string{})
162152

163-
// create a github installation link (org ID matched to installation ID)
153+
// Ensure the installation link exists (idempotent operation)
164154
_, err = models.DB.CreateGithubInstallationLink(org, installationId64)
165155
if err != nil {
166156
slog.Error("Error creating GitHub installation link",
@@ -172,120 +162,9 @@ func (d DiggerController) GithubAppCallbackPage(c *gin.Context) {
172162
return
173163
}
174164

175-
slog.Debug("Getting GitHub client",
176-
"appId", *installation.AppID,
177-
"installationId", installationId64,
178-
)
179-
180-
client, _, err := d.GithubClientProvider.Get(*installation.AppID, installationId64)
181-
if err != nil {
182-
slog.Error("Error retrieving GitHub client",
183-
"appId", *installation.AppID,
184-
"installationId", installationId64,
185-
"error", err,
186-
)
187-
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching organisation"})
188-
return
189-
}
190-
191-
// we get repos accessible to this installation
192-
slog.Debug("Listing repositories for installation", "installationId", installationId64)
193-
194-
repos, err := github.ListGithubRepos(client)
195-
if err != nil {
196-
slog.Error("Failed to list existing repositories",
197-
"installationId", installationId64,
198-
"error", err,
199-
)
200-
c.String(http.StatusInternalServerError, "Failed to list existing repos: %v", err)
201-
return
202-
}
203-
204-
// resets all existing installations (soft delete)
205-
slog.Debug("Resetting existing GitHub installations",
206-
"installationId", installationId,
207-
)
208-
209-
var AppInstallation models.GithubAppInstallation
210-
err = models.DB.GormDB.Model(&AppInstallation).Where("github_installation_id=?", installationId).Update("status", models.GithubAppInstallDeleted).Error
211-
if err != nil {
212-
slog.Error("Failed to update GitHub installations",
213-
"installationId", installationId,
214-
"error", err,
215-
)
216-
c.String(http.StatusInternalServerError, "Failed to update github installations: %v", err)
217-
return
218-
}
219-
220-
// reset all existing repos (soft delete)
221-
slog.Debug("Soft deleting existing repositories",
222-
"orgId", orgId,
223-
)
224-
225-
var ExistingRepos []models.Repo
226-
err = models.DB.GormDB.Delete(ExistingRepos, "organisation_id=?", orgId).Error
227-
if err != nil {
228-
slog.Error("Could not delete repositories",
229-
"orgId", orgId,
230-
"error", err,
231-
)
232-
c.String(http.StatusInternalServerError, "could not delete repos: %v", err)
233-
return
234-
}
235-
236-
// here we mark repos that are available one by one
237-
slog.Info("Adding repositories to organization",
238-
"orgId", orgId,
239-
"repoCount", len(repos),
240-
)
241-
242-
for i, repo := range repos {
243-
repoFullName := *repo.FullName
244-
repoOwner := strings.Split(*repo.FullName, "/")[0]
245-
repoName := *repo.Name
246-
repoUrl := fmt.Sprintf("https://%v/%v", utils.GetGithubHostname(), repoFullName)
247-
248-
slog.Debug("Processing repository",
249-
"index", i+1,
250-
"repoFullName", repoFullName,
251-
"repoOwner", repoOwner,
252-
"repoName", repoName,
253-
)
254-
255-
_, err := models.DB.GithubRepoAdded(
256-
installationId64,
257-
*installation.AppID,
258-
*installation.Account.Login,
259-
*installation.Account.ID,
260-
repoFullName,
261-
)
262-
if err != nil {
263-
slog.Error("Error recording GitHub repository",
264-
"repoFullName", repoFullName,
265-
"error", err,
266-
)
267-
c.String(http.StatusInternalServerError, "github repos added error: %v", err)
268-
return
269-
}
270-
271-
cloneUrl := *repo.CloneURL
272-
defaultBranch := *repo.DefaultBranch
273-
274-
_, _, err = createOrGetDiggerRepoForGithubRepo(repoFullName, repoOwner, repoName, repoUrl, installationId64, *installation.AppID, defaultBranch, cloneUrl)
275-
if err != nil {
276-
slog.Error("Error creating or getting Digger repo",
277-
"repoFullName", repoFullName,
278-
"error", err,
279-
)
280-
c.String(http.StatusInternalServerError, "createOrGetDiggerRepoForGithubRepo error: %v", err)
281-
return
282-
}
283-
}
284-
285-
slog.Info("GitHub app callback processed successfully",
165+
slog.Info("GitHub app callback processed",
286166
"installationId", installationId64,
287167
"orgId", orgId,
288-
"repoCount", len(repos),
289168
)
290169

291170
c.HTML(http.StatusOK, "github_success.tmpl", gin.H{})

0 commit comments

Comments
 (0)