diff --git a/src/Action/Issue.psm1 b/src/Action/Issue.psm1 index 239b10c..781b065 100644 --- a/src/Action/Issue.psm1 +++ b/src/Action/Issue.psm1 @@ -1,6 +1,9 @@ Join-Path $PSScriptRoot '..\Helpers.psm1' | Import-Module +Join-Path $PSScriptRoot '..\Github.psm1' | Import-Module Join-Path $PSScriptRoot 'Issue' | Get-ChildItem -Filter '*.psm1' | Select-Object -ExpandProperty Fullname | Import-Module + + function Test-Hash { param ( [Parameter(Mandatory = $true)] @@ -63,13 +66,63 @@ function Test-Hash { } else { Write-Log 'Hash mismatch confirmed.' - $masterBranch = ((Invoke-GithubRequest "repos/$REPOSITORY").Content | ConvertFrom-Json).default_branch - $message = @('You are right. Thank you for reporting.') + # Use GraphQL to fetch repository info and PRs in parallel + $owner, $repo = $REPOSITORY -split '/' + $graphqlQuery = @" + query(`$owner:String!, `$repo:String!) { + repository(owner:`$owner, name:`$repo) { + defaultBranchRef { + name + } + pullRequests(states:OPEN, first:100, orderBy:{field:UPDATED_AT, direction:DESC}) { + nodes { + number + title + body + baseRefName + } + } + } + rateLimit { + remaining + } + } +"@ + + $masterBranch = $null + $prs = $null + + try { + Write-Log 'Attempting GraphQL query for repository and PRs...' + $response = Invoke-GithubGraphQL -Query $graphqlQuery -Variables @{ + owner = $owner + repo = $repo + } + + $content = $response.Content | ConvertFrom-Json + if ($content.data -and $content.data.repository) { + $masterBranch = $content.data.repository.defaultBranchRef.name + $prs = $content.data.repository.pullRequests.nodes + Write-Log "GraphQL query succeeded. Remaining rate limit: $($content.data.rateLimit.remaining)" + } else { + throw 'GraphQL returned no data' + } + } catch { + Write-Log "GraphQL query failed, falling back to REST API: $($_.Exception.Message)" + # Fallback to REST API + $masterBranch = ((Invoke-GithubRequest "repos/$REPOSITORY").Content | ConvertFrom-Json).default_branch + $prs = (Invoke-GithubRequest "repos/$REPOSITORY/pulls?state=open&base=$masterBranch&sort=updated").Content | ConvertFrom-Json + } + +$message = @('You are right. Thank you for reporting.') # TODO: Post labels at the end of function Add-Label -ID $IssueID -Label 'verified', 'hash-fix-needed' - $prs = (Invoke-GithubRequest "repos/$REPOSITORY/pulls?state=open&base=$masterBranch&sorting=updated").Content | ConvertFrom-Json $titleToBePosted = "$manifestNameAsInBucket@$($man.version): Fix hash" - $prs = $prs | Where-Object { $_.title -eq $titleToBePosted } + $prs = $prs | Where-Object { + $_.title -eq $titleToBePosted -and + (($_.baseRefName -eq $masterBranch) -or ($_.base.ref -eq $masterBranch)) + } + # There is alreay PR for if ($prs.Count -gt 0) { @@ -88,9 +141,16 @@ function Test-Hash { Invoke-GithubRequest "repos/$REPOSITORY/pulls/$prID" -Method Patch -Body @{ 'body' = (@("- Closes #$IssueID", $pr.body) -join "`r`n") } Add-Label -ID $IssueID -Label 'duplicate' } else { - # Check if default branch is protected - if (((Invoke-GithubRequest "repos/$REPOSITORY/branches/$masterBranch").Content | ConvertFrom-Json).protected) { - Write-Log 'PR - Create new branch and post PR' + # Check if default branch is protected (need REST API for this as GraphQL doesn't expose it) + $isProtected = $false + try { + $isProtected = ((Invoke-GithubRequest "repos/$REPOSITORY/branches/$masterBranch").Content | ConvertFrom-Json).protected + } catch { + Write-Log "Failed to check branch protection status: $($_.Exception.Message). Assuming branch is protected for safety." + $isProtected = $true + } + + if ($isProtected) { $branch = "$manifestNameAsInBucket-hash-fix-$(Get-Random -Maximum 258258258)" diff --git a/src/Github.psm1 b/src/Github.psm1 index abd5133..61e5317 100644 --- a/src/Github.psm1 +++ b/src/Github.psm1 @@ -1,5 +1,196 @@ Join-Path $PSScriptRoot 'Helpers.psm1' | Import-Module +function Invoke-GithubGraphQL { + <# + .SYNOPSIS + Invoke authenticated GitHub GraphQL API request with retry logic. + .PARAMETER Query + GraphQL query string. + .PARAMETER Variables + Hashtable of variables for the GraphQL query. + .PARAMETER MaxRetries + Maximum number of retry attempts on rate limit. Default is 3. + .EXAMPLE + Invoke-GithubGraphQL -Query 'query { viewer { login } }' + #> + param( + [Parameter(Mandatory)] + [String] $Query, + [Hashtable] $Variables, + [Int] $MaxRetries = 3 + ) + + $graphqlUrl = 'https://api.github.com/graphql' + $retryCount = 0 + $success = $false + $response = $null + + while ($retryCount -lt $MaxRetries -and -not $success) { + try { + $body = @{ + 'query' = $Query + } + if ($Variables) { + $body['variables'] = $Variables + } + + $parameters = @{ + 'Headers' = @{ + 'Authorization' = "Bearer $env:GITHUB_TOKEN" + 'Accept' = 'application/json' + } + 'Method' = 'Post' + 'Uri' = $graphqlUrl + 'Body' = (ConvertTo-Json $body -Depth 10) + 'ContentType' = 'application/json' + } + + Write-Log 'GraphQL Request' $parameters.Uri + Write-Log 'GraphQL Query' $Query + + $response = Invoke-WebRequest @parameters + $content = $response.Content | ConvertFrom-Json + + if ($content.errors) { + Write-Log 'GraphQL Errors' ($content.errors | ConvertTo-Json -Depth 10) + + # Check for rate limit errors + $isRateLimit = $content.errors | Where-Object { $_.type -eq 'RATE_LIMITED' } + if ($isRateLimit) { + $retryCount++ + if ($retryCount -lt $MaxRetries) { + $waitTime = [Math]::Pow(2, $retryCount) * 3 + Write-Log "Rate limit hit, waiting $waitTime seconds before retry ($retryCount/$MaxRetries)" + Start-Sleep -Seconds $waitTime + continue + } else { + throw "Rate limit exceeded after $MaxRetries retries" + } + } + + throw "GraphQL query failed: $($content.errors[0].message)" + } + + $env:GH_REQUEST_COUNTER = ([int] $env:GH_REQUEST_COUNTER) + 1 + $success = $true + + return $response + } catch { + Write-Log "GraphQL request failed: $($_.Exception.Message)" + + $retryCount++ + if ($retryCount -lt $MaxRetries -and $_.Exception.Message -match 'rate limit|timeout|temporary') { + $waitTime = [Math]::Pow(2, $retryCount) * 3 + Write-Log "Retrying in $waitTime seconds..." + Start-Sleep -Seconds $waitTime + } else { + throw + } + } + } + + if (-not $success) { + throw "GraphQL request failed after $MaxRetries retries: $($response | Out-String)" + } +} + +function Invoke-GithubGraphQLParallel { + <# + .SYNOPSIS + Execute multiple GraphQL queries in parallel to avoid rate limits. + .PARAMETER Queries + Array of hashtables containing Query and Variables. + .EXAMPLE + $queries = @( + @{ Query = 'query($owner:String!, $name:String!) { repository(owner:$owner, name:$name) { defaultBranch } }'; Variables = @{ owner = 'octocat'; name = 'Hello-World' } } + ) + Invoke-GithubGraphQLParallel -Queries $queries + #> + param( + [AllowEmptyCollection()] + [Hashtable[]] $Queries + ) + + # Early return for empty or null queries + if ($null -eq $Queries -or $Queries.Count -eq 0) { + return @{ Results = @(); Errors = @(); FallbackUsed = $false } + } + + $results = @() + $errors = @() + $runspacePool = [runspacefactory]::CreateRunspacePool(1, [Math]::Min(5, $Queries.Count)) + $runspacePool.Open() + + $jobs = @() + foreach ($q in $Queries) { + $powershell = [powershell]::Create() + $powershell.RunspacePool = $runspacePool + $powershell.AddScript({ + param($Query, $Variables, $Token) + $env:GITHUB_TOKEN = $Token + $body = @{ 'query' = $Query } + if ($Variables) { $body['variables'] = $Variables } + + $parameters = @{ + 'Headers' = @{ 'Authorization' = "Bearer $Token" } + 'Method' = 'Post' + 'Uri' = 'https://api.github.com/graphql' + 'Body' = (ConvertTo-Json $body -Depth 10) + 'ContentType' = 'application/json' + } + + try { + $response = Invoke-WebRequest @parameters + $payload = $response.Content | ConvertFrom-Json + if ($payload.errors) { + return @{ Success = $false; Error = ($payload.errors | ConvertTo-Json -Depth 10) } + } + return @{ Success = $true; Data = $response } + } catch { + return @{ Success = $false; Error = $_.Exception.Message } + } + }).AddArgument($q.Query).AddArgument($q.Variables).AddArgument($env:GITHUB_TOKEN) > $null + + $jobs += @{ + PowerShell = $powershell + AsyncResult = $powershell.BeginInvoke() + Query = $q + } + } + + $successfulCount = 0 + try { + foreach ($job in $jobs) { + try { + $result = $job.PowerShell.EndInvoke($job.AsyncResult) + if ($result.Success) { + $results += $result.Data + $successfulCount++ + } else { + Write-Log "Parallel query failed: $($result.Error)" + $errors += @{ Query = $job.Query; Error = $result.Error } + } + } finally { + $job.PowerShell.Dispose() + } + } + + # Update parent process counter after all runspaces complete + $env:GH_REQUEST_COUNTER = ([int]$env:GH_REQUEST_COUNTER) + $successfulCount + } finally { + $runspacePool.Close() + $runspacePool.Dispose() + } + + + if ($errors.Count -gt 0) { + Write-Log "Some GraphQL queries failed with errors: $($errors.Count)" + } + + return @{ Results = $results; Errors = $errors; FallbackUsed = $false } +} + + function Invoke-GithubRequest { <# .SYNOPSIS @@ -14,7 +205,7 @@ function Invoke-GithubRequest { Invoke-GithubRequest 'repos/User/Repo/pulls' -Method 'Post' -Body @{ 'body' = 'body' } #> param( - [Parameter(Mandatory, ValueFromPipeline)] + [Parameter(Mandatory)] [String] $Query, [Microsoft.PowerShell.Commands.WebRequestMethod] $Method = 'Get', [Hashtable] $Body @@ -313,5 +504,5 @@ function Get-LogURL { return $logURL } -Export-ModuleMember -Function Invoke-GithubRequest, Add-Comment, Get-AllChangedFilesInPR, New-Issue, Close-Issue, ` +Export-ModuleMember -Function Invoke-GithubRequest, Invoke-GithubGraphQL, Invoke-GithubGraphQLParallel, Add-Comment, Get-AllChangedFilesInPR, New-Issue, Close-Issue, ` Add-Label, Remove-Label, Get-RateLimit, Get-JobID, Get-LogURL