extensions/specrew-speckit/scripts/provider-github.ps1
|
#!/usr/bin/env pwsh # GitHub reference adapter (Feature 182, Iteration 2). # # The ONLY place `gh` / the GitHub API is used. The methodology + validator core stay forge-neutral # and never import this. Everything here is fail-open: if `gh` is absent or unauthenticated, the # adapter degrades to an honest `ci-only`/`manual` report — it NEVER promises protection it cannot # verify, and `apply_protection` is human-approved and never auto-run. function Test-SpecrewGhAvailable { [CmdletBinding()] param() $cmd = Get-Command gh -ErrorAction SilentlyContinue return [bool]$cmd } function Get-SpecrewGitHubCapability { # detect_capability for GitHub: read-only. Maps visibility/plan to the achievable mechanism. # Fail-open: any gh error -> ci-only (CI runs anywhere) with an honest constraint. [CmdletBinding()] param([string]$ProjectPath = '.') if (-not (Test-SpecrewGhAvailable)) { return [ordered]@{ provider = 'github'; mechanism = 'ci-only'; constraints = @('gh CLI not available; cannot detect branch-protection capability — the CI work-kind check still runs (ci-only). Install/authenticate gh for capability detection.') } } $visibility = $null # Run gh against the SUPPLIED ProjectPath, not the accidental current directory (a caller may invoke # this from elsewhere). try/finally restores the location even on error; fail-open is preserved. $savedLocation = Get-Location try { if (Test-Path -LiteralPath $ProjectPath -PathType Container) { Set-Location -LiteralPath $ProjectPath } $json = & gh repo view --json visibility,isPrivate 2>$null | Out-String if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($json)) { $obj = $json | ConvertFrom-Json $visibility = if ($obj.visibility) { [string]$obj.visibility } elseif ($obj.isPrivate) { 'private' } else { 'public' } } } catch { $visibility = $null } finally { Set-Location -LiteralPath $savedLocation -ErrorAction SilentlyContinue } # NOTE: the billing plan (Free/Pro/Team/Enterprise) is not reliably exposed via gh, and the owner # type (user/org) does not determine it — so we deliberately do NOT fetch it and instead report the # conservative, honest mechanism + a plan/visibility caveat below (rather than guessing from owner type). if ($null -eq $visibility) { return [ordered]@{ provider = 'github'; mechanism = 'ci-only'; constraints = @('gh present but repo visibility/capability not readable (unauthenticated or no access); reporting ci-only honestly.') } } # GitHub branch protection: available for public repos on Free/Free-for-orgs and for public/private # on Pro/Team/Enterprise. Rulesets: public on Free, public/private on Pro/Team/Enterprise Cloud. # Without the plan we report the conservative, honest mechanism + the caveat. $mechanism = 'branch-protection' $constraints = [System.Collections.Generic.List[string]]::new() $constraints.Add("visibility=$visibility; branch protection + rulesets availability depend on plan/visibility (GitHub docs) — verify against the repo's plan before relying on it.") | Out-Null if ($visibility -eq 'public') { $constraints.Add('public repo: protected branches + rulesets are available on Free and up.') | Out-Null } else { $constraints.Add('private/internal repo: protected branches/rulesets require Pro/Team/Enterprise — if on Free, this degrades to ci-only/manual.') | Out-Null } return [ordered]@{ provider = 'github'; mechanism = $mechanism; constraints = @($constraints.ToArray()) } } function Get-SpecrewGitHubExistingProtection { # Brownfield read (FR-021): the repo's EXISTING protection on a branch. Read-only; fail-open. # Runs gh against the SUPPLIED ProjectPath, not the accidental current directory. [CmdletBinding()] param([Parameter(Mandatory = $true)][string]$Branch, [string]$ProjectPath = '.') if (-not (Test-SpecrewGhAvailable)) { return [ordered]@{ readable = $false; reason = 'gh not available'; protected = $null } } $savedLocation = Get-Location try { if (Test-Path -LiteralPath $ProjectPath -PathType Container) { Set-Location -LiteralPath $ProjectPath } $null = & gh api "repos/{owner}/{repo}/branches/$Branch/protection" 2>$null if ($LASTEXITCODE -eq 0) { return [ordered]@{ readable = $true; protected = $true; reason = "branch '$Branch' has protection configured" } } return [ordered]@{ readable = $true; protected = $false; reason = "branch '$Branch' has no protection (or not readable with the current token)" } } catch { return [ordered]@{ readable = $false; protected = $null; reason = 'gh api error (fail-open)' } } finally { Set-Location -LiteralPath $savedLocation -ErrorAction SilentlyContinue } } function Invoke-SpecrewGitHubApplyProtection { # apply_protection for GitHub. GUARDED: refused unless -Approved (human approval). Uses the # caller's own gh auth / GITHUB_TOKEN (Specrew holds no secret). Returns a result; the actual # mutation is intentionally gated so a dry default never changes repo security. [CmdletBinding()] param( [Parameter(Mandatory = $true)]$Governance, [switch]$Approved, [switch]$Execute ) if (-not $Approved) { return [ordered]@{ applied = $false; reason = 'apply_protection requires explicit human approval (-Approved); refused (DP-S2).' } } if (-not (Test-SpecrewGhAvailable)) { return [ordered]@{ applied = $false; reason = 'gh CLI not available; cannot apply protection (degrade to ci-only/manual).' } } if (-not $Execute) { return [ordered]@{ applied = $false; reason = 'approved but -Execute not set: describe-only. Re-run with -Execute to mutate (uses your own gh auth; Specrew holds no secret).' } } # A real mutation would PUT repos/{owner}/{repo}/branches/<b>/protection here, derived from # $Governance.branch_model. Kept gated: the live mutation is exercised only under explicit # human -Approved -Execute, validated at the dogfood/beta (honest phased posture). return [ordered]@{ applied = $false; reason = 'live apply is human-approved + validated at dogfood/beta; not auto-run in this path (honest phased).' } } |