11// Package app provides the core application logic for the GitHub bot.
2- // coordinates webhook processing, Okta sync, and PR compliance checks.
2+ // Coordinates webhook processing, Okta sync, and PR compliance checks.
33package app
44
55import (
@@ -11,10 +11,8 @@ import (
1111 "github.com/cruxstack/github-ops-app/internal/config"
1212 internalerrors "github.com/cruxstack/github-ops-app/internal/errors"
1313 "github.com/cruxstack/github-ops-app/internal/github/client"
14- "github.com/cruxstack/github-ops-app/internal/github/webhooks"
1514 "github.com/cruxstack/github-ops-app/internal/notifiers"
1615 "github.com/cruxstack/github-ops-app/internal/okta"
17- gh "github.com/google/go-github/v79/github"
1816)
1917
2018// App is the main application instance containing all clients and
@@ -28,7 +26,7 @@ type App struct {
2826}
2927
3028// New creates a new App instance with configured clients.
31- // initializes GitHub, Okta, and Slack clients based on config.
29+ // Initializes GitHub, Okta, and Slack clients based on config.
3230func New (ctx context.Context , cfg * config.Config ) (* App , error ) {
3331 logger := config .NewLogger ()
3432
@@ -87,7 +85,7 @@ type ScheduledEvent struct {
8785}
8886
8987// ProcessScheduledEvent handles scheduled events (e.g., cron jobs).
90- // routes to appropriate handlers based on event action.
88+ // Routes to appropriate handlers based on event action.
9189func (a * App ) ProcessScheduledEvent (ctx context.Context , evt ScheduledEvent ) error {
9290 if a .Config .DebugEnabled {
9391 j , _ := json .Marshal (evt )
@@ -105,7 +103,7 @@ func (a *App) ProcessScheduledEvent(ctx context.Context, evt ScheduledEvent) err
105103}
106104
107105// ProcessWebhook handles incoming GitHub webhook events.
108- // supports pull_request, team, and membership events.
106+ // Supports pull_request, team, and membership events.
109107func (a * App ) ProcessWebhook (ctx context.Context , payload []byte , eventType string ) error {
110108 if a .Config .DebugEnabled {
111109 a .Logger .Debug ("received webhook" , slog .String ("event_type" , eventType ))
@@ -123,312 +121,6 @@ func (a *App) ProcessWebhook(ctx context.Context, payload []byte, eventType stri
123121 }
124122}
125123
126- // handleOktaSync executes Okta group synchronization to GitHub teams.
127- // sends Slack notification with sync results if configured.
128- func (a * App ) handleOktaSync (ctx context.Context ) error {
129- if ! a .Config .IsOktaSyncEnabled () {
130- a .Logger .Info ("okta sync is not enabled, skipping" )
131- return nil
132- }
133-
134- if a .OktaClient == nil || a .GitHubClient == nil {
135- return errors .Wrap (internalerrors .ErrClientNotInit , "okta or github client" )
136- }
137-
138- syncer := okta .NewSyncer (a .OktaClient , a .GitHubClient , a .Config .OktaSyncRules , a .Config .OktaSyncSafetyThreshold , a .Logger )
139- syncResult , err := syncer .Sync (ctx )
140- if err != nil {
141- return errors .Wrap (err , "okta sync failed" )
142- }
143-
144- a .Logger .Info ("okta sync completed" , slog .Int ("report_count" , len (syncResult .Reports )))
145-
146- if a .Notifier != nil {
147- if err := a .Notifier .NotifyOktaSync (ctx , syncResult .Reports , a .Config .GitHubOrg ); err != nil {
148- a .Logger .Warn ("failed to send slack notification" , slog .String ("error" , err .Error ()))
149- }
150- }
151-
152- if a .Config .OktaOrphanedUserNotifications {
153- syncedTeams := make ([]string , 0 , len (syncResult .Reports ))
154- for _ , report := range syncResult .Reports {
155- syncedTeams = append (syncedTeams , report .GitHubTeam )
156- }
157-
158- orphanedReport , err := syncer .DetectOrphanedUsers (ctx , syncedTeams )
159- if err != nil {
160- a .Logger .Warn ("failed to detect orphaned users" , slog .String ("error" , err .Error ()))
161- } else if orphanedReport != nil && len (orphanedReport .OrphanedUsers ) > 0 {
162- a .Logger .Info ("orphaned users detected" , slog .Int ("count" , len (orphanedReport .OrphanedUsers )))
163-
164- if a .Notifier != nil {
165- if err := a .Notifier .NotifyOrphanedUsers (ctx , orphanedReport ); err != nil {
166- a .Logger .Warn ("failed to send orphaned users notification" , slog .String ("error" , err .Error ()))
167- }
168- }
169- }
170- }
171-
172- return nil
173- }
174-
175- // handlePullRequestWebhook processes GitHub pull request webhook events.
176- // checks merged PRs for branch protection compliance violations.
177- func (a * App ) handlePullRequestWebhook (ctx context.Context , payload []byte ) error {
178- prEvent , err := webhooks .ParsePullRequestEvent (payload )
179- if err != nil {
180- return err
181- }
182-
183- if ! prEvent .IsMerged () {
184- if a .Config .DebugEnabled {
185- a .Logger .Debug ("pr not merged, skipping" , slog .Int ("pr_number" , prEvent .Number ))
186- }
187- return nil
188- }
189-
190- baseBranch := prEvent .GetBaseBranch ()
191- if ! a .Config .ShouldMonitorBranch (baseBranch ) {
192- if a .Config .DebugEnabled {
193- a .Logger .Debug ("branch not monitored, skipping" , slog .String ("branch" , baseBranch ))
194- }
195- return nil
196- }
197-
198- ghClient := a .GitHubClient
199-
200- if prEvent .GetInstallationID () != 0 && prEvent .GetInstallationID () != a .Config .GitHubInstallationID {
201- installClient , err := client .NewAppClientWithBaseURL (
202- a .Config .GitHubAppID ,
203- prEvent .GetInstallationID (),
204- a .Config .GitHubAppPrivateKey ,
205- a .Config .GitHubOrg ,
206- a .Config .GitHubBaseURL ,
207- )
208- if err != nil {
209- return errors .Wrapf (err , "failed to create client for installation %d" , prEvent .GetInstallationID ())
210- }
211- ghClient = installClient
212- }
213-
214- if ghClient == nil {
215- return errors .Wrap (internalerrors .ErrClientNotInit , "github client" )
216- }
217-
218- owner := prEvent .GetRepoOwner ()
219- repo := prEvent .GetRepoName ()
220-
221- result , err := ghClient .CheckPRCompliance (ctx , owner , repo , prEvent .Number )
222- if err != nil {
223- return errors .Wrapf (err , "failed to check pr #%d compliance" , prEvent .Number )
224- }
225-
226- if result .WasBypassed () {
227- a .Logger .Info ("pr bypassed branch protection" ,
228- slog .Int ("pr_number" , prEvent .Number ),
229- slog .String ("branch" , baseBranch ))
230-
231- if a .Notifier != nil {
232- repoFullName := prEvent .GetRepoFullName ()
233- if err := a .Notifier .NotifyPRBypass (ctx , result , repoFullName ); err != nil {
234- a .Logger .Warn ("failed to send slack notification" , slog .String ("error" , err .Error ()))
235- }
236- }
237- } else if a .Config .DebugEnabled {
238- a .Logger .Debug ("pr complied with branch protection" , slog .Int ("pr_number" , prEvent .Number ))
239- }
240-
241- return nil
242- }
243-
244- // handleTeamWebhook processes GitHub team webhook events.
245- // triggers Okta sync when team changes are made externally.
246- func (a * App ) handleTeamWebhook (ctx context.Context , payload []byte ) error {
247- teamEvent , err := webhooks .ParseTeamEvent (payload )
248- if err != nil {
249- return err
250- }
251-
252- if ! a .Config .IsOktaSyncEnabled () {
253- if a .Config .DebugEnabled {
254- a .Logger .Debug ("okta sync not enabled, skipping team webhook" )
255- }
256- return nil
257- }
258-
259- if a .shouldIgnoreWebhookChange (ctx , teamEvent ) {
260- if a .Config .DebugEnabled {
261- a .Logger .Debug ("ignoring team change from bot/app" ,
262- slog .String ("action" , teamEvent .Action ),
263- slog .String ("sender" , teamEvent .GetSenderLogin ()))
264- }
265- return nil
266- }
267-
268- a .Logger .Info ("external team change detected, triggering sync" ,
269- slog .String ("action" , teamEvent .Action ),
270- slog .String ("team" , teamEvent .GetTeamSlug ()),
271- slog .String ("sender" , teamEvent .GetSenderLogin ()))
272-
273- return a .handleOktaSync (ctx )
274- }
275-
276- // handleMembershipWebhook processes GitHub membership webhook events.
277- // triggers Okta sync when team memberships are changed externally.
278- func (a * App ) handleMembershipWebhook (ctx context.Context , payload []byte ) error {
279- membershipEvent , err := webhooks .ParseMembershipEvent (payload )
280- if err != nil {
281- return err
282- }
283-
284- if ! membershipEvent .IsTeamScope () {
285- if a .Config .DebugEnabled {
286- a .Logger .Debug ("membership event is not team scope, skipping" )
287- }
288- return nil
289- }
290-
291- if ! a .Config .IsOktaSyncEnabled () {
292- if a .Config .DebugEnabled {
293- a .Logger .Debug ("okta sync not enabled, skipping membership webhook" )
294- }
295- return nil
296- }
297-
298- if a .shouldIgnoreWebhookChange (ctx , membershipEvent ) {
299- if a .Config .DebugEnabled {
300- a .Logger .Debug ("ignoring membership change from bot/app" ,
301- slog .String ("action" , membershipEvent .Action ),
302- slog .String ("team" , membershipEvent .GetTeamSlug ()),
303- slog .String ("sender" , membershipEvent .GetSenderLogin ()))
304- }
305- return nil
306- }
307-
308- a .Logger .Info ("external membership change detected, triggering sync" ,
309- slog .String ("action" , membershipEvent .Action ),
310- slog .String ("team" , membershipEvent .GetTeamSlug ()),
311- slog .String ("sender" , membershipEvent .GetSenderLogin ()))
312-
313- return a .handleOktaSync (ctx )
314- }
315-
316- // webhookSender provides sender information for webhook events.
317- type webhookSender interface {
318- GetSenderType () string
319- GetSenderLogin () string
320- }
321-
322- // shouldIgnoreWebhookChange checks if a webhook should be ignored.
323- // ignores changes made by bots or the GitHub App itself to prevent loops.
324- func (a * App ) shouldIgnoreWebhookChange (ctx context.Context , event webhookSender ) bool {
325- if event .GetSenderType () == "Bot" {
326- return true
327- }
328-
329- if a .GitHubClient != nil {
330- appSlug , err := a .GitHubClient .GetAppSlug (ctx )
331- if err != nil {
332- a .Logger .Warn ("failed to get app slug" , slog .String ("error" , err .Error ()))
333- return false
334- }
335- if event .GetSenderLogin () == appSlug + "[bot]" {
336- return true
337- }
338- }
339-
340- return false
341- }
342-
343- // handleSlackTest sends test notifications to Slack with sample data.
344- // useful for verifying Slack connectivity and previewing message formats.
345- func (a * App ) handleSlackTest (ctx context.Context ) error {
346- if a .Notifier == nil {
347- return errors .New ("slack is not configured" )
348- }
349-
350- // test 1: PR bypass notification
351- if err := a .Notifier .NotifyPRBypass (ctx , fakePRComplianceResult (), "acme-corp/demo-repo" ); err != nil {
352- return errors .Wrap (err , "failed to send test pr bypass notification" )
353- }
354- a .Logger .Info ("sent test pr bypass notification" )
355-
356- // test 2: Okta sync notification
357- if err := a .Notifier .NotifyOktaSync (ctx , fakeOktaSyncReports (), "acme-corp" ); err != nil {
358- return errors .Wrap (err , "failed to send test okta sync notification" )
359- }
360- a .Logger .Info ("sent test okta sync notification" )
361-
362- // test 3: Orphaned users notification
363- if err := a .Notifier .NotifyOrphanedUsers (ctx , fakeOrphanedUsersReport ()); err != nil {
364- return errors .Wrap (err , "failed to send test orphaned users notification" )
365- }
366- a .Logger .Info ("sent test orphaned users notification" )
367-
368- return nil
369- }
370-
371- // fakePRComplianceResult returns sample PR compliance data for testing.
372- func fakePRComplianceResult () * client.PRComplianceResult {
373- prNumber := 42
374- prTitle := "Add new authentication feature"
375- prURL := "https://github.com/acme-corp/demo-repo/pull/42"
376- mergedByLogin := "test-user"
377-
378- return & client.PRComplianceResult {
379- PR : & gh.PullRequest {
380- Number : & prNumber ,
381- Title : & prTitle ,
382- HTMLURL : & prURL ,
383- MergedBy : & gh.User {
384- Login : & mergedByLogin ,
385- },
386- },
387- BaseBranch : "main" ,
388- UserHasBypass : true ,
389- UserBypassReason : "repository admin" ,
390- Violations : []client.ComplianceViolation {
391- {Type : "insufficient_reviews" , Description : "required 2 approving reviews, had 0" },
392- {Type : "missing_status_check" , Description : "required check 'ci/build' did not pass" },
393- },
394- }
395- }
396-
397- // fakeOktaSyncReports returns sample Okta sync reports for testing.
398- func fakeOktaSyncReports () []* okta.SyncReport {
399- return []* okta.SyncReport {
400- {
401- Rule : "engineering-team" ,
402- OktaGroup : "Engineering" ,
403- GitHubTeam : "engineering" ,
404- MembersAdded : []string {"alice" , "bob" },
405- MembersRemoved : []string {"charlie" },
406- },
407- {
408- Rule : "platform-team" ,
409- OktaGroup : "Platform" ,
410- GitHubTeam : "platform" ,
411- // no changes
412- },
413- {
414- Rule : "security-team" ,
415- OktaGroup : "Security" ,
416- GitHubTeam : "security" ,
417- MembersAdded : []string {"dave" },
418- MembersSkippedExternal : []string {"external-contractor" },
419- MembersSkippedNoGHUsername : []string {"new-hire@example.com" },
420- Errors : []string {"failed to fetch group members: rate limited" },
421- },
422- }
423- }
424-
425- // fakeOrphanedUsersReport returns sample orphaned users data for testing.
426- func fakeOrphanedUsersReport () * okta.OrphanedUsersReport {
427- return & okta.OrphanedUsersReport {
428- OrphanedUsers : []string {"orphan-user-1" , "orphan-user-2" , "legacy-bot" },
429- }
430- }
431-
432124// StatusResponse contains application status and feature flags.
433125type StatusResponse struct {
434126 Status string `json:"status"`
0 commit comments