[CmdletBinding()] param( [string]$Remote = 'origin', [string]$BaseUrl, [string]$Owner, [string]$Repo, [string]$ApiToken, [string]$Ref = 'main', [string]$WorkflowId = 'ci.yml', [string]$RequiredLabel, [switch]$DispatchIfMissing, [switch]$Wait, [ValidateRange(30, 21600)] [int]$TimeoutSeconds = 1800, [ValidateRange(5, 300)] [int]$PollSeconds = 15 ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Invoke-GitCapture { param( [Parameter(Mandatory = $true)] [string[]]$ArgumentList ) $output = & git @ArgumentList 2>&1 if ($LASTEXITCODE -ne 0) { throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")" } return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() } function Resolve-GiteaRepositoryContext { param( [string]$RemoteName, [string]$RemoteUrl ) if ([string]::IsNullOrWhiteSpace($RemoteUrl)) { $RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', $RemoteName) } $normalized = $RemoteUrl.Trim() if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') { return [pscustomobject]@{ BaseUrl = $Matches[1] Owner = $Matches[2] Repo = $Matches[3] } } if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') { $giteaHost = $Matches[1] $port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" } return [pscustomobject]@{ BaseUrl = "https://$giteaHost$port" Owner = $Matches[3] Repo = $Matches[4] } } throw "Could not parse Gitea remote URL: $normalized" } function Resolve-ApiToken { param( [Parameter(Mandatory = $true)] [string]$ResolvedBaseUrl, [Parameter(Mandatory = $true)] [string]$RepoOwner, [Parameter(Mandatory = $true)] [string]$RepoName, [string]$ExplicitToken ) if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) { return $ExplicitToken } foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) { $value = [Environment]::GetEnvironmentVariable($name) if (-not [string]::IsNullOrWhiteSpace($value)) { return $value } } $uri = [Uri]$ResolvedBaseUrl $lines = @( 'protocol=https' "host=$($uri.Authority)" "path=$RepoOwner/$RepoName.git" '' ) | git credential fill $passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1 if ($null -eq $passwordLine) { throw 'Could not resolve a Gitea API token from the current environment or git credential manager.' } return $passwordLine.Substring(9) } function Invoke-GiteaApi { param( [Parameter(Mandatory = $true)] [ValidateSet('GET', 'POST')] [string]$Method, [Parameter(Mandatory = $true)] [string]$Uri, [Parameter(Mandatory = $true)] [string]$Token, [object]$Body ) $invokeArgs = @{ Method = $Method Uri = $Uri Headers = @{ Authorization = "token $Token" } ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { $invokeArgs.ContentType = 'application/json' $invokeArgs.Body = $Body | ConvertTo-Json -Depth 8 } try { return Invoke-RestMethod @invokeArgs } catch { $response = $_.Exception.Response if ($null -eq $response) { throw } $statusCode = [int]$response.StatusCode $payload = '' if ($response -is [System.Net.Http.HttpResponseMessage]) { if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) { $payload = $_.ErrorDetails.Message } elseif ($null -ne $response.Content) { try { $payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { $payload = '' } } } elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') { $stream = $response.GetResponseStream() if ($null -ne $stream) { $reader = [System.IO.StreamReader]::new($stream) try { $payload = $reader.ReadToEnd() } finally { $reader.Dispose() $stream.Dispose() } } } throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload" } } function Get-OptionalProperty { param( [Parameter(Mandatory = $true)] [object]$InputObject, [Parameter(Mandatory = $true)] [string]$Name ) $property = $InputObject.PSObject.Properties[$Name] if ($null -eq $property) { return $null } return $property.Value } function Resolve-WorkflowRunnerLabel { param( [Parameter(Mandatory = $true)] [string]$WorkspaceRoot, [Parameter(Mandatory = $true)] [string]$WorkflowFileName, [string]$ExplicitLabel ) if (-not [string]::IsNullOrWhiteSpace($ExplicitLabel)) { return $ExplicitLabel } $workflowPath = Join-Path $WorkspaceRoot (Join-Path '.gitea\workflows' $WorkflowFileName) if (-not (Test-Path -LiteralPath $workflowPath -PathType Leaf)) { throw "Workflow file not found: $workflowPath" } $content = Get-Content -Raw -LiteralPath $workflowPath $match = [regex]::Match($content, '(?m)^\s*runs-on:\s*[''"]?([^''"\r\n\[\], ]+)') if (-not $match.Success) { throw "Could not resolve runs-on label from $workflowPath" } return $match.Groups[1].Value } function Find-HeadRun { param( [Parameter(Mandatory = $true)] [object]$Runs, [Parameter(Mandatory = $true)] [string]$HeadSha, [Parameter(Mandatory = $true)] [string]$ExpectedWorkflowId ) $matching = @( $Runs.workflow_runs | Where-Object { $_.head_sha -eq $HeadSha } | Where-Object { $workflowId = '' $path = '' if ($_.PSObject.Properties.Name -contains 'workflow_id') { $workflowId = [string]$_.workflow_id } if ($_.PSObject.Properties.Name -contains 'path') { $path = [string]$_.path } ($workflowId -eq $ExpectedWorkflowId) -or ($path -like "$ExpectedWorkflowId@*") -or [string]::IsNullOrWhiteSpace($workflowId) } | Sort-Object id -Descending ) if ($matching.Count -eq 0) { return $null } return $matching[0] } $workspaceRoot = Split-Path -Parent $PSScriptRoot Push-Location -LiteralPath $workspaceRoot try { $context = Resolve-GiteaRepositoryContext -RemoteName $Remote if ([string]::IsNullOrWhiteSpace($BaseUrl)) { $BaseUrl = $context.BaseUrl } if ([string]::IsNullOrWhiteSpace($Owner)) { $Owner = $context.Owner } if ([string]::IsNullOrWhiteSpace($Repo)) { $Repo = $context.Repo } $ApiToken = Resolve-ApiToken -ResolvedBaseUrl $BaseUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken $requiredRunnerLabel = Resolve-WorkflowRunnerLabel -WorkspaceRoot $workspaceRoot -WorkflowFileName $WorkflowId -ExplicitLabel $RequiredLabel $apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo" $workflow = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/workflows/$WorkflowId" -Token $ApiToken if ($workflow.state -ne 'active') { throw "Workflow $WorkflowId is not active; current state is $($workflow.state)." } $branch = Invoke-GiteaApi -Method GET -Uri "$apiBase/branches/$Ref" -Token $ApiToken $headSha = [string]$branch.commit.id if ([string]::IsNullOrWhiteSpace($headSha)) { throw "Could not resolve head SHA for ref $Ref." } $runners = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runners" -Token $ApiToken $matchingRunners = @( $runners.runners | Where-Object { $_.status -eq 'online' } | Where-Object { $labelNames = @($_.labels | ForEach-Object { $_.name }) $labelNames -contains $requiredRunnerLabel } ) if ($matchingRunners.Count -eq 0) { throw "No online Gitea runner exposes required label '$requiredRunnerLabel'." } $runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken $run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId $dispatched = $false if ($null -eq $run -and $DispatchIfMissing) { Invoke-GiteaApi -Method POST -Uri "$apiBase/actions/workflows/$WorkflowId/dispatches" -Token $ApiToken -Body @{ ref = $Ref } | Out-Null $dispatched = $true } elseif ($null -eq $run) { throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha). Pass -DispatchIfMissing to trigger one." } $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) while ($Wait -and ($null -eq $run -or $run.status -ne 'completed')) { if ([DateTimeOffset]::UtcNow -ge $deadline) { $status = if ($null -eq $run) { 'missing' } else { [string]$run.status } throw "Timed out waiting for $WorkflowId at $headSha; last status was $status." } Start-Sleep -Seconds $PollSeconds $runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken $run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId } if ($null -eq $run) { throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha)." } $jobs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs/$($run.id)/jobs" -Token $ApiToken $jobSummaries = @( $jobs.jobs | ForEach-Object { [pscustomobject]@{ id = $_.id name = $_.name status = $_.status conclusion = Get-OptionalProperty -InputObject $_ -Name 'conclusion' runner_name = Get-OptionalProperty -InputObject $_ -Name 'runner_name' labels = @($_.labels) } } ) $runConclusion = Get-OptionalProperty -InputObject $run -Name 'conclusion' if ($run.status -eq 'completed' -and $runConclusion -ne 'success') { throw "Gitea Actions run $($run.id) completed with conclusion '$runConclusion'." } [pscustomobject]@{ ok = ($run.status -eq 'completed' -and $runConclusion -eq 'success') base_url = $BaseUrl owner = $Owner repo = $Repo ref = $Ref head_sha = $headSha workflow_id = $WorkflowId workflow_state = $workflow.state required_runner_label = $requiredRunnerLabel online_runner_count = $matchingRunners.Count dispatched = $dispatched run = [pscustomobject]@{ id = $run.id run_number = $run.run_number event = $run.event status = $run.status conclusion = $runConclusion head_sha = $run.head_sha head_branch = $run.head_branch display_title = $run.display_title } jobs = $jobSummaries } | ConvertTo-Json -Depth 8 } finally { Pop-Location }