Public/Get-GitHubRunnerActivity.ps1
|
function Get-GitHubRunnerActivity { <# .SYNOPSIS Returns a point-in-time picture of every self-hosted runner registered to the given repositories, joined to the job each one is executing. .DESCRIPTION The GitHub API splits this picture across two unrelated endpoints. The runners endpoint knows which runners exist and whether each is `busy`, but not what it is busy WITH. The jobs endpoint knows the workflow, the job, and the step, but is reachable only per workflow-run. This function performs the join - matching a job's `runner_name` back to the registered runner - so callers get one row per runner with the work on it attached. Built to be called repeatedly (a polling dashboard is the intended caller), so the call pattern is shaped around the hourly rate limit: - The runner list and the run lists are fetched with conditional requests. Pass the same -Cache hashtable on every tick and an unchanged list answers 304, which GitHub does not charge. - The in-progress runs are fetched only when at least one runner reports itself busy. A job cannot be on our fleet if no runner is executing anything, so an idle repo skips that fan-out entirely. - The per-run jobs call is never cached. A job stepping forward does not alter the parent run object, so a cached run list would answer 304 while the step an operator is watching moves on. It is withheld from the cache rather than routed around the conditional caller, which keeps the largest consumer on a busy tick inside .RateLimit. Queued jobs are reported separately, filtered to those whose requested labels intersect the labels our runners advertise - a job waiting on a GitHub-hosted image is not this fleet's business. Failures degrade the report rather than empty it. A repository that cannot be polled at all (auth, rate limit, network) is recorded in .Failures and the remaining repositories still report; a single run whose jobs cannot be read is recorded there too, and its repository still reports every runner row, with the affected runner reading busy but carrying no job detail. .PARAMETER Token Bearer token (PAT or GitHub App installation token). Needs read access to Actions and to self-hosted runner administration on each repo. .PARAMETER Repository One or more 'owner/repo' slugs to poll. .PARAMETER Cache Conditional-request cache, MUTATED IN PLACE. Hand the same hashtable back on each call to keep repeat polls off the rate limit. Omit it and every request is unconditional (correct, just more expensive). .OUTPUTS [PSCustomObject] with four members: .Runners - one row per registered runner. Repository, Name, Id, Status ('online'/'offline'), Busy, Labels, and - when the runner is executing something - WorkflowName, JobName, CurrentStep, StartedAt (UTC), Url. .QueuedJobs - jobs waiting for a runner in this fleet: Repository, WorkflowName, JobName, Labels, QueuedAt (UTC), Url. .Failures - Repository + Message for each repo that could not be polled, and for each individual run whose jobs could not be read. A repository can therefore appear in both .Runners and .Failures. .RateLimit - Remaining, Limit, ResetsAt (UTC) from the last response that carried the headers; $null if none did. Every request the function makes reports into it, the uncached per-run jobs calls included. .EXAMPLE $cache = @{} while ($true) { $activity = Get-GitHubRunnerActivity -Token $tok ` -Repository 'Klark-Morrigan/Common-Automation' -Cache $cache $activity.Runners | Format-Table Name, Status, JobName, CurrentStep Start-Sleep -Seconds 10 } #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Token, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Repository, [Parameter()] [AllowNull()] [hashtable] $Cache ) # GitHub's maximum page size. Every list this function reads (runners per # repo, non-terminal runs per repo, jobs per run) is far below it in any # realistic fleet, so one page is the whole answer and no pagination loop # is warranted. A fleet that outgrows this would silently truncate - the # ceiling to revisit if that ever becomes plausible. $maxItemsPerPage = 100 $runnerRows = [System.Collections.Generic.List[object]]::new() $queuedRows = [System.Collections.Generic.List[object]]::new() $failures = [System.Collections.Generic.List[object]]::new() $rateLimit = $null foreach ($slug in $Repository) { try { # -- Registered runners ------------------------------------------ $runnersResponse = Invoke-GitHubConditionalGet ` -Token $Token ` -Cache $Cache ` -Endpoint "repos/$slug/actions/runners?per_page=$maxItemsPerPage" if ($null -ne $runnersResponse.RateLimit) { $rateLimit = $runnersResponse.RateLimit } $repoRunners = @(Get-GitHubResponseProperty $runnersResponse.Value 'runners' @()) # Union of every label this repo's fleet advertises. Used below to # tell a queued job that could land on one of our runners from one # waiting on a GitHub-hosted image. Case-insensitive because # GitHub matches runner labels that way. $fleetLabels = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase) foreach ($runner in $repoRunners) { foreach ($label in @(Get-GitHubResponseProperty $runner 'labels' @())) { $labelName = Get-GitHubResponseProperty $label 'name' if ($labelName) { $null = $fleetLabels.Add([string] $labelName) } } } # -- Non-terminal runs worth opening ----------------------------- # 'queued' is always fetched: a queue with nothing running is # precisely the state an operator most needs to see. 'in_progress' # is fetched only when a runner is actually busy (see .DESCRIPTION). $anyBusy = @($repoRunners | Where-Object { Get-GitHubResponseProperty $_ 'busy' $false }).Count -gt 0 $runStatuses = if ($anyBusy) { @('queued', 'in_progress') } else { @('queued') } $runs = [System.Collections.Generic.List[object]]::new() foreach ($runStatus in $runStatuses) { $runsResponse = Invoke-GitHubConditionalGet ` -Token $Token ` -Cache $Cache ` -Endpoint "repos/$slug/actions/runs?status=$runStatus&per_page=$maxItemsPerPage" if ($null -ne $runsResponse.RateLimit) { $rateLimit = $runsResponse.RateLimit } foreach ($run in @(Get-GitHubResponseProperty $runsResponse.Value 'workflow_runs' @())) { $runs.Add($run) } } # -- Open each run's jobs and bucket them ------------------------ # jobByRunner: runner name -> the job it is executing. Built here # and consumed by the runner rows below. $jobByRunner = @{} $seenRunIds = [System.Collections.Generic.HashSet[long]]::new() foreach ($run in $runs) { $runId = Get-GitHubResponseProperty $run 'id' # Dedupe defensively: a run that transitions between the two # list calls could appear under both statuses. if ($null -eq $runId -or -not $seenRunIds.Add([long] $runId)) { continue } try { # -Cache $null is the deliberate half of this call; see the # call-pattern notes in .DESCRIPTION for why. $jobsResponse = Invoke-GitHubConditionalGet ` -Token $Token ` -Cache $null ` -Endpoint "repos/$slug/actions/runs/$runId/jobs?per_page=$maxItemsPerPage" } catch { # One unreadable run must not cost the repository its whole # board. The runner rows below come from the runners call, # which succeeded well before this point, and the row shape # already allows for a busy runner with no detail attached. # Record what was lost and carry on with the other runs. $failures.Add([PSCustomObject]@{ Repository = $slug Message = "run $runId jobs: $($_.Exception.Message)" }) continue } if ($null -ne $jobsResponse.RateLimit) { $rateLimit = $jobsResponse.RateLimit } foreach ($job in @(Get-GitHubResponseProperty $jobsResponse.Value 'jobs' @())) { $jobStatus = Get-GitHubResponseProperty $job 'status' $runnerName = Get-GitHubResponseProperty $job 'runner_name' if ($jobStatus -eq 'in_progress' -and $runnerName) { $jobByRunner[[string] $runnerName] = $job continue } if ($jobStatus -ne 'queued') { continue } # A queued job names the labels it is waiting for. Report # it only if this fleet could satisfy at least one of them. $jobLabels = @(Get-GitHubResponseProperty $job 'labels' @()) $isForThisFleet = @($jobLabels | Where-Object { $fleetLabels.Contains([string] $_) }).Count -gt 0 if (-not $isForThisFleet) { continue } # created_at is when the job entered the queue; started_at # is only meaningful once it is picked up. Prefer the # former and fall back for older API responses. $queuedAt = Get-GitHubResponseProperty $job 'created_at' if (-not $queuedAt) { $queuedAt = Get-GitHubResponseProperty $job 'started_at' } $queuedRows.Add([PSCustomObject]@{ Repository = $slug WorkflowName = Get-GitHubResponseProperty $job 'workflow_name' JobName = Get-GitHubResponseProperty $job 'name' Labels = $jobLabels QueuedAt = ConvertFrom-GitHubTimestamp $queuedAt Url = Get-GitHubResponseProperty $job 'html_url' }) } } # -- One row per registered runner ------------------------------- foreach ($runner in $repoRunners) { $runnerName = [string] (Get-GitHubResponseProperty $runner 'name') # $null when idle - and also when the runner is busy with work # we could not open (a run outside the fetched page). The row # then reads BUSY with no detail, which is the honest answer. $job = if ($runnerName -and $jobByRunner.ContainsKey($runnerName)) { $jobByRunner[$runnerName] } else { $null } $runnerRows.Add([PSCustomObject]@{ Repository = $slug Name = $runnerName Id = Get-GitHubResponseProperty $runner 'id' Status = Get-GitHubResponseProperty $runner 'status' 'unknown' Busy = [bool] (Get-GitHubResponseProperty $runner 'busy' $false) Labels = @(@(Get-GitHubResponseProperty $runner 'labels' @()) | ForEach-Object { Get-GitHubResponseProperty $_ 'name' }) WorkflowName = Get-GitHubResponseProperty $job 'workflow_name' JobName = Get-GitHubResponseProperty $job 'name' CurrentStep = Get-GitHubJobCurrentStep $job StartedAt = ConvertFrom-GitHubTimestamp ( Get-GitHubResponseProperty $job 'started_at') Url = Get-GitHubResponseProperty $job 'html_url' }) } } catch { $failures.Add([PSCustomObject]@{ Repository = $slug Message = $_.Exception.Message }) } } [PSCustomObject]@{ Runners = $runnerRows.ToArray() QueuedJobs = $queuedRows.ToArray() Failures = $failures.ToArray() RateLimit = $rateLimit } } |