forked from Crockan/MercuryToolbox
366 lines
12 KiB
PowerShell
366 lines
12 KiB
PowerShell
[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
|
|
}
|