44import json
55import time
66import logging
7- from typing import Any , List , Optional
7+ from typing import Any , List , Optional , Union
88from ghastoolkit .errors import GHASToolkitError , GHASToolkitTypeError
99from ghastoolkit .octokit .github import GitHub , Repository
1010from ghastoolkit .octokit .octokit import OctoItem , RestRequest , loadOctoItem
@@ -69,14 +69,149 @@ def __str__(self) -> str:
6969 return f"CodeAlert({ self .number } , '{ self .state } ', '{ self .tool_name } ', '{ self .rule_id } ')"
7070
7171
72+ @dataclass
73+ class CodeScanningTool (OctoItem ):
74+ """Code Scanning Tool.
75+
76+ https://docs.github.com/rest/code-scanning/code-scanning#list-code-scanning-analyses-for-a-repository
77+ """
78+
79+ name : str
80+ """Tool Name"""
81+ guid : Optional [str ] = None
82+ """Tool GUID"""
83+ version : Optional [str ] = None
84+ """Tool Version"""
85+
86+ def __str__ (self ) -> str :
87+ """To String."""
88+ if self .version :
89+ return f"CodeScanningTool({ self .name } , '{ self .version } ')"
90+ else :
91+ return f"CodeScanningTool({ self .name } )"
92+
93+ def __repr__ (self ) -> str :
94+ return self .__str__ ()
95+
96+
97+ @dataclass
98+ class CodeScanningAnalysisEnvironment (OctoItem ):
99+ """Code Scanning Analysis Environment.
100+
101+ https://docs.github.com/rest/code-scanning/code-scanning
102+ """
103+
104+ language : Optional [str ] = None
105+ """Language"""
106+
107+ build_mode : Optional [str ] = None
108+ """CodeQL Build Mode"""
109+
110+ def __str__ (self ) -> str :
111+ """To String."""
112+ if self .language :
113+ return f"CodeScanningAnalysisEnvironment({ self .language } )"
114+ return "CodeScanningAnalysisEnvironment()"
115+
116+
117+ @dataclass
118+ class CodeScanningAnalysis (OctoItem ):
119+ """Code Scanning Analysis.
120+
121+ https://docs.github.com/rest/code-scanning/code-scanning#list-code-scanning-analyses-for-a-repository
122+ """
123+
124+ id : int
125+ """Unique Identifier"""
126+ ref : str
127+ """Reference (branch, tag, etc)"""
128+ commit_sha : str
129+ """Commit SHA"""
130+ analysis_key : str
131+ """Analysis Key"""
132+ environment : CodeScanningAnalysisEnvironment
133+ """Environment"""
134+ error : str
135+ """Error"""
136+ created_at : str
137+ """Created At"""
138+ results_count : int
139+ """Results Count"""
140+ rules_count : int
141+ """Rules Count"""
142+ url : str
143+ """URL"""
144+ sarif_id : str
145+ """SARIF ID"""
146+ tool : CodeScanningTool
147+ """Tool Information"""
148+ deletable : bool
149+ """Deletable"""
150+ warning : str
151+ """Warning"""
152+
153+ category : Optional [str ] = None
154+ """Category"""
155+
156+ def __post_init__ (self ) -> None :
157+ if isinstance (self .environment , str ):
158+ # Load the environment as JSON
159+ self .environment = loadOctoItem (
160+ CodeScanningAnalysisEnvironment , json .loads (self .environment )
161+ )
162+ if isinstance (self .tool , dict ):
163+ # Load the tool as JSON
164+ self .tool = loadOctoItem (CodeScanningTool , self .tool )
165+
166+ @property
167+ def language (self ) -> Optional [str ]:
168+ """Language from the Environment."""
169+ return self .environment .language
170+
171+ @property
172+ def build_mode (self ) -> Optional [str ]:
173+ """Build Mode from the Environment."""
174+ return self .environment .build_mode
175+
176+ def __str__ (self ) -> str :
177+ """To String."""
178+ return f"CodeScanningAnalysis({ self .id } , '{ self .ref } ', '{ self .tool .name } ')"
179+
180+
181+ @dataclass
182+ class CodeScanningConfiguration (OctoItem ):
183+ """Code Scanning Configuration for Default Setup.
184+
185+ https://docs.github.com/rest/code-scanning/code-scanning#get-a-code-scanning-default-setup-configuration--parameters
186+ """
187+
188+ state : str
189+ """State of the Configuration"""
190+ query_suite : str
191+ """Query Suite"""
192+ languages : list [str ]
193+ """Languages"""
194+ updated_at : str
195+ """Updated At"""
196+ schedule : str
197+ """Scheduled (weekly)"""
198+
199+ def __str__ (self ) -> str :
200+ """To String."""
201+ return f"CodeScanningConfiguration('{ self .state } ', '{ self .query_suite } ', '{ self .languages } ')"
202+
203+ def __repr__ (self ) -> str :
204+ return self .__str__ ()
205+
206+
72207class CodeScanning :
73208 """Code Scanning."""
74209
75210 def __init__ (
76211 self ,
77212 repository : Optional [Repository ] = None ,
78213 retry_count : int = 1 ,
79- retry_sleep : int = 15 ,
214+ retry_sleep : Union [ int , float ] = 15 ,
80215 ) -> None :
81216 """Code Scanning REST API.
82217
@@ -90,7 +225,7 @@ def __init__(
90225 self .repository = repository or GitHub .repository
91226 self .tools : List [str ] = []
92227
93- self .setup : Optional [dict ] = None
228+ self .setup : Optional [CodeScanningConfiguration ] = None
94229
95230 if not self .repository :
96231 raise GHASToolkitError ("CodeScanning requires Repository to be set" )
@@ -125,7 +260,7 @@ def isCodeQLDefaultSetup(self) -> bool:
125260 if not self .setup :
126261 self .setup = self .getDefaultConfiguration ()
127262
128- return self .setup .get ( " state" , "not-configured" ) == "configured"
263+ return self .setup .state == "configured"
129264
130265 def enableDefaultSetup (
131266 self ,
@@ -167,7 +302,7 @@ def getOrganizationAlerts(self, state: str = "open") -> list[CodeAlert]:
167302 docs = "https://docs.github.com/en/rest/code-scanning#list-code-scanning-alerts-for-an-organization" ,
168303 )
169304
170- def getDefaultConfiguration (self ) -> dict :
305+ def getDefaultConfiguration (self ) -> CodeScanningConfiguration :
171306 """Get Default Code Scanning Configuration.
172307
173308 Permissions:
@@ -177,7 +312,7 @@ def getDefaultConfiguration(self) -> dict:
177312 """
178313 result = self .rest .get ("/repos/{owner}/{repo}/code-scanning/default-setup" )
179314 if isinstance (result , dict ):
180- self .setup = result
315+ self .setup = loadOctoItem ( CodeScanningConfiguration , result )
181316 return self .setup
182317
183318 raise GHASToolkitTypeError (
@@ -239,11 +374,7 @@ def getAlertsInPR(self, base: str) -> list[CodeAlert]:
239374 # Try merge and then head
240375 analysis = self .getAnalyses (reference = self .repository .reference )
241376 if len (analysis ) == 0 :
242- analysis = self .getAnalyses (
243- reference = self .repository .reference .replace ("/merge" , "/head" )
244- )
245- if len (analysis ) == 0 :
246- raise GHASToolkitError ("No analyses found for the PR" )
377+ raise GHASToolkitError ("No analyses found for the PR" )
247378
248379 # For CodeQL results using Default Setup
249380 reference = analysis [0 ].get ("ref" )
@@ -299,7 +430,7 @@ def getAlertInstances(
299430
300431 def getAnalyses (
301432 self , reference : Optional [str ] = None , tool : Optional [str ] = None
302- ) -> list [dict ]:
433+ ) -> list [CodeScanningAnalysis ]:
303434 """Get a list of all the analyses for a given repository.
304435
305436 This function will retry X times with a Y second sleep between each retry to
@@ -315,13 +446,23 @@ def getAnalyses(
315446
316447 https://docs.github.com/en/enterprise-cloud@latest/rest/code-scanning#list-code-scanning-analyses-for-a-repository
317448 """
449+ ref = reference or self .repository .reference
450+ logger .debug (f"Getting Analyses for { ref } " )
451+ if ref is None :
452+ raise GHASToolkitError ("Reference is required for getting analyses" )
453+
318454 counter = 0
455+
456+ logger .debug (
457+ f"Fetching Analyses (retries { self .retry_count } every { self .retry_sleep } s)"
458+ )
459+
319460 while counter < self .retry_count :
320461 counter += 1
321462
322463 results = self .rest .get (
323464 "/repos/{org}/{repo}/code-scanning/analyses" ,
324- {"tool_name" : tool , "ref" : reference or self . repository . reference },
465+ {"tool_name" : tool , "ref" : ref },
325466 )
326467 if not isinstance (results , list ):
327468 raise GHASToolkitTypeError (
@@ -332,13 +473,37 @@ def getAnalyses(
332473 docs = "https://docs.github.com/en/enterprise-cloud@latest/rest/code-scanning#list-code-scanning-analyses-for-a-repository" ,
333474 )
334475
335- if len (results ) > 0 and self .retry_count > 1 :
336- logger .info (
337- f"No analyses found, retrying { counter } /{ self .retry_count } )"
476+ # Try default setup `head` if no results (required for default setup)
477+ if (
478+ len (results ) == 0
479+ and self .repository .isInPullRequest ()
480+ and (ref .endswith ("/merge" ) or ref .endswith ("/head" ))
481+ ):
482+ logger .debug ("No analyses found for `merge`, trying `head`" )
483+ results = self .rest .get (
484+ "/repos/{org}/{repo}/code-scanning/analyses" ,
485+ {"tool_name" : tool , "ref" : ref .replace ("/merge" , "/head" )},
338486 )
339- time .sleep (self .retry_sleep )
487+ if not isinstance (results , list ):
488+ raise GHASToolkitTypeError (
489+ "Error getting analyses from Repository" ,
490+ permissions = [
491+ '"Code scanning alerts" repository permissions (read)'
492+ ],
493+ docs = "https://docs.github.com/en/enterprise-cloud@latest/rest/code-scanning#list-code-scanning-analyses-for-a-repository" ,
494+ )
495+
496+ if len (results ) < 0 :
497+ # If the retry count is less than 1, we don't retry
498+ if self .retry_count < 1 :
499+ logger .debug (
500+ f"No analyses found, retrying { counter } /{ self .retry_count } )"
501+ )
502+ time .sleep (self .retry_sleep )
340503 else :
341- return results
504+ return [
505+ loadOctoItem (CodeScanningAnalysis , analysis ) for analysis in results
506+ ]
342507
343508 # If we get here, we have retried the max number of times and still no results
344509 raise GHASToolkitError (
@@ -349,7 +514,7 @@ def getAnalyses(
349514
350515 def getLatestAnalyses (
351516 self , reference : Optional [str ] = None , tool : Optional [str ] = None
352- ) -> list [dict ]:
517+ ) -> list [CodeScanningAnalysis ]:
353518 """Get Latest Analyses for every tool.
354519
355520 Permissions:
@@ -361,16 +526,31 @@ def getLatestAnalyses(
361526 results = []
362527
363528 for analysis in self .getAnalyses (reference , tool ):
364- name = analysis .get ("tool" , {}).get ("name" )
365- if name in tools :
529+ if analysis .tool .name in tools :
366530 continue
367- tools .add (name )
531+ tools .add (analysis . tool . name )
368532 results .append (analysis )
369533
370534 self .tools = list (tools )
371535
372536 return results
373537
538+ def getFailedAnalyses (
539+ self , reference : Optional [str ] = None
540+ ) -> list [CodeScanningAnalysis ]:
541+ """Get Failed Analyses for a given reference. This will return all analyses with errors or warnings.
542+
543+ Permissions:
544+ - "Code scanning alerts" repository permissions (read)
545+
546+ https://docs.github.com/en/rest/code-scanning/code-scanning
547+ """
548+ return [
549+ analysis
550+ for analysis in self .getAnalyses (reference )
551+ if analysis .error != "" or analysis .warning != ""
552+ ]
553+
374554 def getTools (self , reference : Optional [str ] = None ) -> List [str ]:
375555 """Get list of tools from the latest analyses.
376556
0 commit comments