@@ -125,13 +125,17 @@ type SecurityReviewOwner =
125125type SyncResult = {
126126 synced : number ;
127127 errors : number ;
128+ /** Repos where Dependabot alerts are permanently disabled (safe to skip) */
129+ skipped : number ;
130+ /** Repos that returned 404 or are access-blocked (deleted/transferred/inaccessible) */
128131 staleRepos : string [ ] ;
129132} ;
130133
131134type FetchAlertsResult =
132135 | { status : 'success' ; alerts : DependabotAlertRaw [ ] }
133136 | { status : 'repo_not_found' }
134- | { status : 'alerts_disabled' } ;
137+ | { status : 'alerts_disabled' }
138+ | { status : 'access_blocked' } ;
135139
136140function isOrgOwner (
137141 owner : SecurityReviewOwner
@@ -160,6 +164,9 @@ type EnabledOwnerConfig = {
160164 repositories : string [ ] ;
161165 repoNameToId : Map < string , number > ;
162166 slaConfig : SecurityAgentConfig ;
167+ /** Number of selected_repository_ids that are no longer accessible via the installation.
168+ * Non-zero means the app lost access to a configured repo — freshness must not advance. */
169+ missingSelectedRepoCount : number ;
163170} ;
164171
165172export async function getOwnerConfig (
@@ -232,18 +239,28 @@ export async function getOwnerConfig(
232239 }
233240 const securityConfig = parsed . data ;
234241 let selectedRepos : string [ ] ;
235- if (
236- securityConfig . repository_selection_mode === 'selected' &&
237- securityConfig . selected_repository_ids &&
238- securityConfig . selected_repository_ids . length > 0
239- ) {
240- const selectedIds = new Set ( securityConfig . selected_repository_ids ) ;
241- selectedRepos = allRepos . filter ( r => selectedIds . has ( r . id ) ) . map ( r => r . full_name ) ;
242+ let missingSelectedRepoCount = 0 ;
243+ if ( securityConfig . repository_selection_mode === 'selected' ) {
244+ const selectedIds = new Set ( securityConfig . selected_repository_ids ?? [ ] ) ;
245+ if ( selectedIds . size > 0 ) {
246+ const accessibleIds = new Set ( allRepos . map ( r => r . id ) ) ;
247+ selectedRepos = allRepos . filter ( r => selectedIds . has ( r . id ) ) . map ( r => r . full_name ) ;
248+ missingSelectedRepoCount = [ ...selectedIds ] . filter ( id => ! accessibleIds . has ( id ) ) . length ;
249+ } else {
250+ // Mode is 'selected' but no repos are configured — don't fall through to 'all'
251+ selectedRepos = [ ] ;
252+ }
242253 } else {
243254 selectedRepos = allRepos . map ( r => r . full_name ) ;
244255 }
245256
246- if ( selectedRepos . length === 0 ) return null ;
257+ if ( selectedRepos . length === 0 && missingSelectedRepoCount === 0 ) return null ;
258+
259+ if ( missingSelectedRepoCount > 0 ) {
260+ console . warn ( `${ missingSelectedRepoCount } selected repo(s) no longer accessible for owner` , {
261+ owner,
262+ } ) ;
263+ }
247264
248265 return {
249266 owner,
@@ -252,6 +269,7 @@ export async function getOwnerConfig(
252269 repositories : selectedRepos ,
253270 repoNameToId,
254271 slaConfig : { ...DEFAULT_SLA_CONFIG , ...securityConfig } ,
272+ missingSelectedRepoCount,
255273 } ;
256274}
257275
@@ -278,13 +296,27 @@ async function fetchAllDependabotAlerts(
278296 return { status : 'repo_not_found' } ;
279297 }
280298
299+ // 451 "Unavailable for Legal Reasons" — repo is blocked, won't recover.
300+ if ( response . status === 451 ) {
301+ return { status : 'access_blocked' } ;
302+ }
303+
281304 if ( ! response . ok ) {
282305 const body = await response . text ( ) ;
283306
284307 if (
285- response . status === 403 &&
308+ ( response . status === 403 || response . status === 422 ) &&
309+ body . includes ( 'repository access blocked' )
310+ ) {
311+ return { status : 'access_blocked' } ;
312+ }
313+
314+ if (
315+ ( response . status === 403 || response . status === 422 ) &&
286316 ( body . includes ( 'Dependabot alerts are disabled' ) ||
287- body . includes ( 'Dependabot alerts are not available' ) )
317+ body . includes ( 'Dependabot alerts are not available' ) ||
318+ body . includes ( 'archived repositories' ) ||
319+ body . includes ( 'archived repository' ) )
288320 ) {
289321 return { status : 'alerts_disabled' } ;
290322 }
@@ -641,21 +673,72 @@ async function pruneStaleReposFromConfig(
641673 ) ;
642674}
643675
676+ /** Remove selected_repository_ids that are no longer accessible via the GitHub installation.
677+ * Unlike pruneStaleReposFromConfig (which prunes by repo name after sync), this handles
678+ * repos that silently vanished from the installation and were never synced at all. */
679+ async function pruneMissingSelectedRepos (
680+ db : WorkerDb ,
681+ owner : SecurityReviewOwner ,
682+ accessibleRepoIds : Set < number >
683+ ) : Promise < void > {
684+ const rows = await db
685+ . select ( {
686+ id : agent_configs . id ,
687+ config : agent_configs . config ,
688+ } )
689+ . from ( agent_configs )
690+ . where (
691+ and (
692+ eq ( agent_configs . agent_type , 'security_scan' ) ,
693+ eq ( agent_configs . platform , 'github' ) ,
694+ ownerFilter ( owner )
695+ )
696+ )
697+ . limit ( 1 ) ;
698+
699+ if ( rows . length === 0 ) return ;
700+
701+ const parsed = securityAgentConfigSchema . partial ( ) . safeParse ( rows [ 0 ] . config ) ;
702+ if ( ! parsed . success ) return ;
703+ const config = parsed . data ;
704+
705+ if (
706+ config . repository_selection_mode !== 'selected' ||
707+ ! config . selected_repository_ids ||
708+ config . selected_repository_ids . length === 0
709+ ) {
710+ return ;
711+ }
712+
713+ const prunedIds = config . selected_repository_ids . filter ( id => accessibleRepoIds . has ( id ) ) ;
714+ if ( prunedIds . length === config . selected_repository_ids . length ) return ;
715+
716+ const removedCount = config . selected_repository_ids . length - prunedIds . length ;
717+ const updatedConfig = { ...config , selected_repository_ids : prunedIds } ;
718+ await db
719+ . update ( agent_configs )
720+ . set ( { config : updatedConfig , updated_at : sql `now()` } )
721+ . where ( eq ( agent_configs . id , rows [ 0 ] . id ) ) ;
722+
723+ console . warn ( `Pruned ${ removedCount } inaccessible repo ID(s) from config` ) ;
724+ }
725+
644726export async function syncOwner ( params : {
645727 db : WorkerDb ;
646728 gitTokenService : GitTokenService ;
647729 owner : SecurityReviewOwner ;
648730 runId : string ;
649731} ) : Promise < SyncResult > {
650732 const { db : database , gitTokenService, owner, runId } = params ;
733+ const startTime = Date . now ( ) ;
651734
652735 const config = await getOwnerConfig ( database , owner ) ;
653736 if ( ! config ) {
654737 console . info ( `No enabled config for owner, skipping` , { runId, owner } ) ;
655- return { synced : 0 , errors : 0 , staleRepos : [ ] } ;
738+ return { synced : 0 , errors : 0 , skipped : 0 , staleRepos : [ ] } ;
656739 }
657740
658- const totalResult : SyncResult = { synced : 0 , errors : 0 , staleRepos : [ ] } ;
741+ const totalResult : SyncResult = { synced : 0 , errors : 0 , skipped : 0 , staleRepos : [ ] } ;
659742 let firstError : Error | null = null ;
660743 let successfulRepos = 0 ;
661744
@@ -672,6 +755,7 @@ export async function syncOwner(params: {
672755 } ) ;
673756 totalResult . synced += repoResult . synced ;
674757 totalResult . errors += repoResult . errors ;
758+ totalResult . skipped += repoResult . skipped ;
675759 totalResult . staleRepos . push ( ...repoResult . staleRepos ) ;
676760 successfulRepos ++ ;
677761 } catch ( error ) {
@@ -700,6 +784,18 @@ export async function syncOwner(params: {
700784 }
701785 }
702786
787+ // Prune selected repo IDs that silently vanished from the installation
788+ if ( config . missingSelectedRepoCount > 0 ) {
789+ try {
790+ const accessibleRepoIds = new Set ( config . repoNameToId . values ( ) ) ;
791+ await pruneMissingSelectedRepos ( database , owner , accessibleRepoIds ) ;
792+ } catch ( error ) {
793+ console . error ( 'Failed to prune missing selected repos from config' , {
794+ error : error instanceof Error ? error . message : String ( error ) ,
795+ } ) ;
796+ }
797+ }
798+
703799 // Write audit log
704800 const ownerId =
705801 'organizationId' in owner ? ( owner . organizationId ?? 'unknown' ) : ( owner . userId ?? 'unknown' ) ;
@@ -724,6 +820,60 @@ export async function syncOwner(params: {
724820 } ) ;
725821 }
726822
823+ // Only advance owner-level freshness when every repo was actually synced.
824+ // Stale repos (deleted/transferred/access-blocked) block the update because
825+ // they were selected for sync but never refreshed. Skipped repos
826+ // (Dependabot permanently disabled) do NOT block — that's a permanent
827+ // repo-level setting, and blocking here would leave the timestamp stuck.
828+ // Missing selected repos (installation lost access) also block — the repo
829+ // was configured but silently dropped from the accessible list.
830+ if (
831+ totalResult . errors === 0 &&
832+ totalResult . staleRepos . length === 0 &&
833+ config . missingSelectedRepoCount === 0
834+ ) {
835+ try {
836+ await database
837+ . update ( agent_configs )
838+ . set ( {
839+ runtime_state : sql `jsonb_set(
840+ COALESCE(${ agent_configs . runtime_state } , '{}'::jsonb),
841+ '{last_synced_at}',
842+ to_jsonb(now())
843+ )` ,
844+ } )
845+ . where (
846+ and (
847+ eq ( agent_configs . agent_type , 'security_scan' ) ,
848+ eq ( agent_configs . platform , 'github' ) ,
849+ ownerFilter ( owner )
850+ )
851+ ) ;
852+ } catch ( error ) {
853+ console . error ( 'Failed to update last_synced_at in runtime_state' , {
854+ error : error instanceof Error ? error . message : String ( error ) ,
855+ } ) ;
856+ }
857+ }
858+
859+ const syncSummary = {
860+ runId,
861+ ownerId,
862+ reposScanned : config . repositories . length ,
863+ findingsSynced : totalResult . synced ,
864+ errors : totalResult . errors ,
865+ skippedRepos : totalResult . skipped ,
866+ staleRepos : totalResult . staleRepos ,
867+ missingSelectedRepos : config . missingSelectedRepoCount ,
868+ durationMs : Date . now ( ) - startTime ,
869+ } ;
870+
871+ if ( totalResult . synced === 0 && totalResult . errors === 0 && totalResult . skipped === 0 ) {
872+ console . warn ( 'Sync completed with zero findings processed across all repos' , syncSummary ) ;
873+ } else {
874+ console . info ( 'Sync cycle summary' , syncSummary ) ;
875+ }
876+
727877 return totalResult ;
728878}
729879
@@ -746,7 +896,7 @@ async function syncRepo(params: {
746896 slaConfig,
747897 } = params ;
748898 const token = await gitTokenService . getToken ( installationId ) ;
749- const result : SyncResult = { synced : 0 , errors : 0 , staleRepos : [ ] } ;
899+ const result : SyncResult = { synced : 0 , errors : 0 , skipped : 0 , staleRepos : [ ] } ;
750900
751901 const [ repoOwner , repoName ] = repoFullName . split ( '/' ) ;
752902 if ( ! repoOwner || ! repoName ) {
@@ -763,6 +913,13 @@ async function syncRepo(params: {
763913
764914 if ( fetchResult . status === 'alerts_disabled' ) {
765915 console . info ( `Dependabot alerts disabled for ${ repoFullName } , skipping` ) ;
916+ result . skipped = 1 ;
917+ return result ;
918+ }
919+
920+ if ( fetchResult . status === 'access_blocked' ) {
921+ console . warn ( `Repository ${ repoFullName } access blocked, marking as stale` ) ;
922+ result . staleRepos . push ( repoFullName ) ;
766923 return result ;
767924 }
768925
0 commit comments