diff --git a/skills/g3fo-docs/references/middleware/keepalived/fault-analysis.md b/skills/g3fo-docs/references/middleware/keepalived/fault-analysis.md index ae7035a..4978104 100644 --- a/skills/g3fo-docs/references/middleware/keepalived/fault-analysis.md +++ b/skills/g3fo-docs/references/middleware/keepalived/fault-analysis.md @@ -4,23 +4,26 @@ ## 故障场景表 -| 场景编号 | 故障场景描述 | 节点 A 状态(MySQL/Keepalived) | 节点 B 状态(MySQL/Keepalived) | 节点 C 状态(Keepalived) | 各节点有效优先级 | VIP 最终归属 | 关键说明 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | 初始正常状态 | 正常/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级最高,成为 Master | -| 2 | A 的 MySQL 停机,Keepalived 正常 | 故障/正常 | 正常/正常 | 正常 | A=60、B=100、C=40 | Node B | A 扣权重后,B 优先级更高接管 VIP | -| 3 | 场景 2 后,A 的 MySQL 恢复 | 恢复/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级恢复,30 秒后抢占 VIP | -| 4 | A 的 Keepalived 停机 | 正常/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 无选举资格,B 接管 | -| 5 | B 的 MySQL 停机 | 正常/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | B 扣权重后,A 仍为最高 | -| 6 | B 的 Keepalived 停机 | 正常/正常 | 正常/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 无选举资格,A 保持 Master | -| 7 | A、B MySQL 均停机 | 故障/正常 | 故障/正常 | 正常 | A=60、B=50、C=40 | Node A | 无可用 MySQL,但 Keepalived 仍按优先级选举 | -| 8 | A MySQL 停机 + B Keepalived 停机 | 故障/正常 | 无意义/故障(离线) | 正常 | A=60、B=离线、C=40 | Node A | B 离线,A 优先级高于 C(但 MySQL 不可用) | -| 9 | A Keepalived 停机 + B MySQL 停机 | 正常/故障(离线) | 故障/正常 | 正常 | A=离线、B=50、C=40 | Node B | A 离线,B 优先级高于 C(但 MySQL 不可用) | -| 10 | A、B Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 正常 | A=离线、B=离线、C=40 | 无节点绑定 VIP | C 无 VIP 配置,仅参与选举不持有 VIP | -| 11 | A MySQL+Keepalived 均停机 | 故障/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 完全离线,B 正常接管 | -| 12 | B MySQL+Keepalived 均停机 | 正常/正常 | 故障/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 完全离线,A 保持 Master | -| 13 | 场景 7 后,A MySQL 恢复 | 恢复/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | A 优先级恢复最高,接管 VIP | -| 14 | 场景 7 后,B MySQL 恢复 | 故障/正常 | 恢复/正常 | 正常 | A=60、B=100、C=40 | Node B | B 优先级高于 A,接管 VIP | -| 15 | C Keepalived 停机 | 正常/正常 | 正常/正常 | 故障(离线) | A=110、B=100、C=离线 | Node A | C 仅为仲裁,离线不影响 A/B 选举 | -| 16 | A、B MySQL 均停机 + C 停机 | 故障/正常 | 故障/正常 | 故障(离线) | A=60、B=50、C=离线 | Node A | C 离线不影响 A/B 选举 | -| 17 | 所有节点 Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 故障(离线) | 全离线 | 无节点绑定 VIP | 无 Keepalived 参与选举,VIP 失联 | -| 18 | A 恢复但复制异常(check_preempt 失败) | 恢复/正常 | 正常/正常 | 正常 | A=90、B=100、C=40 | Node B | 副库端口正常但 A 本地复制异常,A 降权不抢占 | + +| 场景编号 | 故障场景描述 | 节点 A 状态(MySQL/Keepalived) | 节点 B 状态(MySQL/Keepalived) | 节点 C 状态(Keepalived) | 各节点有效优先级 | VIP 最终归属 | 关键说明 | +| ---- | ---------------------------- | ------------------------- | ------------------------- | ------------------- | ---------------- | --------- | ------------------------------ | +| 1 | 初始正常状态 | 正常/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级最高,成为 Master | +| 2 | A 的 MySQL 停机,Keepalived 正常 | 故障/正常 | 正常/正常 | 正常 | A=60、B=100、C=40 | Node B | A 扣权重后,B 优先级更高接管 VIP | +| 3 | 场景 2 后,A 的 MySQL 恢复 | 恢复/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级恢复,30 秒后抢占 VIP | +| 4 | A 的 Keepalived 停机 | 正常/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 无选举资格,B 接管 | +| 5 | B 的 MySQL 停机 | 正常/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | B 扣权重后,A 仍为最高 | +| 6 | B 的 Keepalived 停机 | 正常/正常 | 正常/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 无选举资格,A 保持 Master | +| 7 | A、B MySQL 均停机 | 故障/正常 | 故障/正常 | 正常 | A=60、B=50、C=40 | Node A | 无可用 MySQL,但 Keepalived 仍按优先级选举 | +| 8 | A MySQL 停机 + B Keepalived 停机 | 故障/正常 | 无意义/故障(离线) | 正常 | A=60、B=离线、C=40 | Node A | B 离线,A 优先级高于 C(但 MySQL 不可用) | +| 9 | A Keepalived 停机 + B MySQL 停机 | 正常/故障(离线) | 故障/正常 | 正常 | A=离线、B=50、C=40 | Node B | A 离线,B 优先级高于 C(但 MySQL 不可用) | +| 10 | A、B Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 正常 | A=离线、B=离线、C=40 | 无节点绑定 VIP | C 无 VIP 配置,仅参与选举不持有 VIP | +| 11 | A MySQL+Keepalived 均停机 | 故障/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 完全离线,B 正常接管 | +| 12 | B MySQL+Keepalived 均停机 | 正常/正常 | 故障/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 完全离线,A 保持 Master | +| 13 | 场景 7 后,A MySQL 恢复 | 恢复/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | A 优先级恢复最高,接管 VIP | +| 14 | 场景 7 后,B MySQL 恢复 | 故障/正常 | 恢复/正常 | 正常 | A=60、B=100、C=40 | Node B | B 优先级高于 A,接管 VIP | +| 15 | C Keepalived 停机 | 正常/正常 | 正常/正常 | 故障(离线) | A=110、B=100、C=离线 | Node A | C 仅为仲裁,离线不影响 A/B 选举 | +| 16 | A、B MySQL 均停机 + C 停机 | 故障/正常 | 故障/正常 | 故障(离线) | A=60、B=50、C=离线 | Node A | C 离线不影响 A/B 选举 | +| 17 | 所有节点 Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 故障(离线) | 全离线 | 无节点绑定 VIP | 无 Keepalived 参与选举,VIP 失联 | +| 18 | A 恢复但复制异常(check_preempt 失败) | 恢复/正常 | 正常/正常 | 正常 | A=90、B=100、C=40 | Node B | 副库端口正常但 A 本地复制异常,A 降权不抢占 | + + diff --git a/skills/git-control/.gitignore b/skills/git-control/.gitignore new file mode 100644 index 0000000..fef0b85 --- /dev/null +++ b/skills/git-control/.gitignore @@ -0,0 +1,2 @@ +# Do not commit token - security +config.local.json diff --git a/skills/git-control/.openskills.json b/skills/git-control/.openskills.json new file mode 100644 index 0000000..0c6d533 --- /dev/null +++ b/skills/git-control/.openskills.json @@ -0,0 +1 @@ +{"source":"local","sourceType":"local","installedAt":"2026-02-27T00:00:00.000Z"} diff --git a/skills/git-control/SKILL.md b/skills/git-control/SKILL.md new file mode 100644 index 0000000..03dc4b8 --- /dev/null +++ b/skills/git-control/SKILL.md @@ -0,0 +1,105 @@ +--- +name: git-control +description: Gitea repository and branch access control. Use when managing Git permissions: enable/disable branch protection (push, merge, read), user read/write permissions on repos, organization-wide repo access, listing orgs/repos, or bulk permission changes. Supports Gitea API (e.g. afe.git:3000). +--- + +# Git Control (Gitea) + +Control Gitea repository and branch permissions via API. Requires Gitea API Token with admin/repo permissions. + +**API Reference**: See `references/gitea_api.md` for endpoints. + +## Prerequisites + +- **Gitea API Token**: Settings → Applications → Generate New Token (repo, admin for org-level) +- **Base URL**: Default `http://afe.git:3000` (override with `-GiteaBaseUrl`) + +**Local config**: Scripts auto-load from `config.local.json` in the skill root when ApiToken is not provided. Format: +```json +{"ApiToken": "your-token", "GiteaBaseUrl": "http://afe.git:3000"} +``` + +## Capabilities + +### 1. Branch Protection (Enable/Disable) + +Control who can push or merge to a branch. + +| Action | Script | Effect | +|--------|--------|--------| +| Lock branch (read-only) | `branch_protect.ps1 -Action lock` | No push, no merge | +| Unlock branch | `branch_protect.ps1 -Action unlock` | Remove protection | +| Restrict merge only | `branch_protect.ps1 -Action merge-only` | No direct push, PR merge allowed | + +```powershell +.\scripts\branch_protect.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -BranchName uat -Action lock +``` + +### 2. List Orgs & Repos + +| Action | Script | +|--------|--------| +| List all organizations | `list_orgs_repos.ps1 -Action list-orgs` | +| List repos in org | `list_orgs_repos.ps1 -Action list-repos -Org G3SF` | +| List all repos (admin) | `list_orgs_repos.ps1 -Action list-all-repos` | + +### 3. User Repo Permissions + +Add, remove, or change a user's permission on a repo. + +| Action | Effect | +|--------|--------| +| Grant read | User can view only | +| Grant write | User can push, create PRs | +| Grant admin | User can manage settings | +| Revoke | Remove collaborator | + +```powershell +.\scripts\user_permission.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -Username john -Action read +.\scripts\user_permission.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -Username john -Action revoke +``` + +### 4. Org-Wide Repo Protection + +Apply branch protection to all repos under an organization. + +```powershell +.\scripts\org_repos_protect.ps1 -ApiToken "..." -Org G3SF -BranchName uat -Action lock +``` + +### 5. Org-Wide User Access + +Revoke or grant a user's collaborator access across all org repos. + +```powershell +.\scripts\org_user_access.ps1 -ApiToken "..." -Org G3SF -Username john -Action revoke +.\scripts\org_user_access.ps1 -ApiToken "..." -Org G3SF -Username john -Action read +``` + +Note: Org repos often use team permissions. For team-based access, use Gitea API team endpoints (see `references/gitea_api.md`). + +## Common Control Functions + +| Function | Description | +|----------|-------------| +| **Lock UAT for release** | Lock `uat` branch on specified repos before release freeze | +| **Unlock for merge** | Temporarily allow merge (add user to merge allowlist) | +| **Audit user access** | List repos a user can access (collaborators + org teams) | +| **Bulk branch lock** | Lock same branch across multiple repos | +| **Read-only maintenance** | Set repo to read-only during maintenance | +| **New member onboarding** | Grant read to org repos for new team member | +| **Offboarding** | Revoke user from all org repos | + +## Script Parameters (Common) + +- `-ApiToken` or `$env:GITEA_TOKEN` +- `-GiteaBaseUrl` (default: http://afe.git:3000) +- `-Owner` / `-Org` (organization name) +- `-Repo` (repository name) +- `-BranchName` (branch pattern, e.g. uat, main) + +## Error Handling + +- 403: Insufficient permissions (need admin for org ops) +- 404: Repo/org/user not found +- 422: Validation error (check API compatibility with Gitea version) diff --git a/skills/git-control/references/gitea_api.md b/skills/git-control/references/gitea_api.md new file mode 100644 index 0000000..6bd724e --- /dev/null +++ b/skills/git-control/references/gitea_api.md @@ -0,0 +1,55 @@ +# Gitea API Reference for Git Control + +Base: `{GiteaBaseUrl}/api/v1` +Auth: `Authorization: token {ApiToken}` + +## Branch Protection + +| Action | Method | Path | +|--------|--------|------| +| List | GET | `/repos/{owner}/{repo}/branch_protections` | +| Create | POST | `/repos/{owner}/{repo}/branch_protections` | +| Get | GET | `/repos/{owner}/{repo}/branch_protections/{name}` | +| Edit | PATCH | `/repos/{owner}/{repo}/branch_protections/{name}` | +| Delete | DELETE | `/repos/{owner}/{repo}/branch_protections/{name}` | + +**CreateBranchProtectionOption** (POST body): +- `branch_name`: pattern (e.g. `uat`, `main`) +- `enable_push`: false = no direct push +- `enable_merge_whitelist`: true + empty lists = no one can merge +- `merge_whitelist_usernames`: [] +- `merge_whitelist_teams`: [] + +## Organizations & Repositories + +| Action | Method | Path | Note | +|--------|--------|------|------| +| List all orgs | GET | `/admin/orgs` | Admin only | +| List org repos | GET | `/orgs/{org}/repos` | Pagination: page, limit | +| List user repos | GET | `/user/repos` | Current user | +| Search repos | GET | `/repos/search` | q, page, limit | + +## Collaborators (User Repo Permissions) + +| Action | Method | Path | +|--------|--------|------| +| List | GET | `/repos/{owner}/{repo}/collaborators` | +| Add/Update | PUT | `/repos/{owner}/{repo}/collaborators/{username}` | +| Remove | DELETE | `/repos/{owner}/{repo}/collaborators/{username}` | +| Check | GET | `/repos/{owner}/{repo}/collaborators/{username}` | + +**PUT body**: `{"permission": "read"|"write"|"admin"}` + +## Organization Teams (Org-level Permissions) + +| Action | Method | Path | +|--------|--------|------| +| List org teams | GET | `/orgs/{org}/teams` | +| List team repos | GET | `/teams/{id}/repos` | +| Add repo to team | PUT | `/teams/{id}/repos/{org}/{repo}` | +| Remove repo from team | DELETE | `/teams/{id}/repos/{org}/{repo}` | +| List team members | GET | `/teams/{id}/members` | +| Add member | PUT | `/teams/{id}/members/{username}` | +| Remove member | DELETE | `/teams/{id}/members/{username}` | + +Team permission: `permission` = "none"|"read"|"write"|"admin"|"owner" diff --git a/skills/git-control/scripts/branch_protect.ps1 b/skills/git-control/scripts/branch_protect.ps1 new file mode 100644 index 0000000..c2929cf --- /dev/null +++ b/skills/git-control/scripts/branch_protect.ps1 @@ -0,0 +1,73 @@ +# Branch protection: lock, unlock, or merge-only +# lock = no push, no merge | unlock = remove protection | merge-only = no direct push, PR merge allowed + +param( + [string]$ApiToken = $env:GITEA_TOKEN, + [string]$GiteaBaseUrl = "http://afe.git:3000", + [string]$Owner, + [string]$Repo, + [string]$BranchName, + [ValidateSet("lock", "unlock", "merge-only")] + [string]$Action = "lock" +) + +$ErrorActionPreference = "Stop" +$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json" +if (-not $ApiToken -and (Test-Path $ConfigPath)) { + $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json + $ApiToken = $cfg.ApiToken + if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl } +} +if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 } +if (-not $Owner -or -not $Repo -or -not $BranchName) { Write-Host "Error: Owner, Repo, BranchName required"; exit 1 } + +$ApiBase = "$GiteaBaseUrl/api/v1" +$ReposPath = "repos/$Owner/$Repo" +$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" } + +# Get existing protections +$Existing = @() +try { + $r = Invoke-RestMethod -Uri "$ApiBase/$ReposPath/branch_protections" -Headers $Headers -Method Get + $Existing = if ($r -is [array]) { $r } else { @($r) } +} catch { + if ($_.Exception.Response.StatusCode -eq 404) { Write-Host "Repo not found"; exit 1 } + throw +} + +$Match = $Existing | Where-Object { $_.rule_name -eq $BranchName -or $_.branch_name -eq $BranchName } | Select-Object -First 1 +$RuleName = if ($Match) { if ($Match.rule_name) { $Match.rule_name } else { $BranchName } } else { $BranchName } + +# Delete existing rule +if ($Match) { + try { + Invoke-RestMethod -Uri "$ApiBase/$ReposPath/branch_protections/$([uri]::EscapeDataString($RuleName))" -Headers $Headers -Method Delete | Out-Null + } catch { Write-Host "Warning: Delete failed: $_" } +} + +if ($Action -eq "unlock") { + Write-Host "Branch $BranchName unlocked (protection removed)" + exit 0 +} + +# Create new rule +$Body = @{ + branch_name = $BranchName + enable_push = if ($Action -eq "merge-only") { $false } else { $false } + enable_push_whitelist = $false + enable_merge_whitelist = if ($Action -eq "lock") { $true } else { $false } + merge_whitelist_usernames = @() + merge_whitelist_teams = @() + block_on_rejected_reviews = $false + block_on_outdated_branch = $false + dismiss_stale_approvals = $false + require_signed_commits = $false +} | ConvertTo-Json + +try { + Invoke-RestMethod -Uri "$ApiBase/$ReposPath/branch_protections" -Headers $Headers -Method Post -Body $Body | Out-Null + Write-Host "Branch ${BranchName}: $Action applied" +} catch { + Write-Host "FAIL: $($_.Exception.Message)" + exit 1 +} diff --git a/skills/git-control/scripts/list_orgs_repos.ps1 b/skills/git-control/scripts/list_orgs_repos.ps1 new file mode 100644 index 0000000..98692c5 --- /dev/null +++ b/skills/git-control/scripts/list_orgs_repos.ps1 @@ -0,0 +1,55 @@ +# List organizations and repositories + +param( + [string]$ApiToken = $env:GITEA_TOKEN, + [string]$GiteaBaseUrl = "http://afe.git:3000", + [ValidateSet("list-orgs", "list-repos", "list-all-repos")] + [string]$Action = "list-orgs", + [string]$Org +) + +$ErrorActionPreference = "Stop" +$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json" +if (-not $ApiToken -and (Test-Path $ConfigPath)) { + $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json + $ApiToken = $cfg.ApiToken + if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl } +} +if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 } + +$ApiBase = "$GiteaBaseUrl/api/v1" +$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" } + +if ($Action -eq "list-orgs") { + try { + $orgs = Invoke-RestMethod -Uri "$ApiBase/admin/orgs?page=1&limit=100" -Headers $Headers -Method Get + $orgs | ForEach-Object { Write-Host $_.username } + } catch { + if ($_.Exception.Response.StatusCode -eq 403) { Write-Host "Admin permission required for list-orgs"; exit 1 } + throw + } + exit 0 +} + +if ($Action -eq "list-repos") { + if (-not $Org) { Write-Host "Error: -Org required for list-repos"; exit 1 } + $repos = @() + $page = 1 + do { + $r = Invoke-RestMethod -Uri "$ApiBase/orgs/$Org/repos?page=$page&limit=50" -Headers $Headers -Method Get + $arr = if ($r -is [array]) { $r } else { @($r) } + $repos += $arr + $page++ + } while ($arr.Count -eq 50) + foreach ($repo in $repos) { Write-Output $repo.name } + exit 0 +} + +if ($Action -eq "list-all-repos") { + $orgs = Invoke-RestMethod -Uri "$ApiBase/admin/orgs?page=1&limit=100" -Headers $Headers -Method Get + foreach ($o in $orgs) { + $r = Invoke-RestMethod -Uri "$ApiBase/orgs/$($o.username)/repos?page=1&limit=100" -Headers $Headers -Method Get + $r | ForEach-Object { Write-Host "$($o.username)/$($_.name)" } + } + exit 0 +} diff --git a/skills/git-control/scripts/org_repos_protect.ps1 b/skills/git-control/scripts/org_repos_protect.ps1 new file mode 100644 index 0000000..6275a69 --- /dev/null +++ b/skills/git-control/scripts/org_repos_protect.ps1 @@ -0,0 +1,35 @@ +# Apply branch protection to all repos in an organization + +param( + [string]$ApiToken = $env:GITEA_TOKEN, + [string]$GiteaBaseUrl = "http://afe.git:3000", + [string]$Org, + [string]$BranchName, + [ValidateSet("lock", "unlock")] + [string]$Action = "lock" +) + +$ErrorActionPreference = "Stop" +$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json" +if (-not $ApiToken -and (Test-Path $ConfigPath)) { + $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json + $ApiToken = $cfg.ApiToken + if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl } +} +if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 } +if (-not $Org -or -not $BranchName) { Write-Host "Error: Org, BranchName required"; exit 1 } + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$BranchScript = Join-Path $ScriptDir "branch_protect.ps1" + +$repos = @(& "$ScriptDir\list_orgs_repos.ps1" -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Action list-repos -Org $Org) +if (-not $repos -or $repos.Count -eq 0) { Write-Host "No repos found in org $Org"; exit 0 } + +$count = 0 +foreach ($r in $repos) { + if (-not $r) { continue } + Write-Host "Processing $Org/$r..." + & $BranchScript -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Owner $Org -Repo $r -BranchName $BranchName -Action $Action + $count++ +} +Write-Host "Done. Processed $count repos." diff --git a/skills/git-control/scripts/org_user_access.ps1 b/skills/git-control/scripts/org_user_access.ps1 new file mode 100644 index 0000000..5c9fb12 --- /dev/null +++ b/skills/git-control/scripts/org_user_access.ps1 @@ -0,0 +1,35 @@ +# Revoke or grant a user's access across all repos in an organization +# Uses collaborator API - only affects repos where user is direct collaborator + +param( + [string]$ApiToken = $env:GITEA_TOKEN, + [string]$GiteaBaseUrl = "http://afe.git:3000", + [string]$Org, + [string]$Username, + [ValidateSet("revoke", "read", "write")] + [string]$Action = "revoke" +) + +$ErrorActionPreference = "Stop" +$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json" +if (-not $ApiToken -and (Test-Path $ConfigPath)) { + $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json + $ApiToken = $cfg.ApiToken + if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl } +} +if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 } +if (-not $Org -or -not $Username) { Write-Host "Error: Org, Username required"; exit 1 } + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Repos = @(& "$ScriptDir\list_orgs_repos.ps1" -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Action list-repos -Org $Org) +$UserScript = Join-Path $ScriptDir "user_permission.ps1" + +$count = 0 +foreach ($Repo in $Repos) { + if (-not $Repo) { continue } + Write-Host "Processing $Org/$Repo..." + $null = & $UserScript -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Owner $Org -Repo $Repo -Username $Username -Action $Action 2>&1 + if ($LASTEXITCODE -eq 0) { $count++ } +} +Write-Host "Done. Updated $count repos where $Username was collaborator." +Write-Host "Note: Org repos may use team permissions. Check org teams for full control." diff --git a/skills/git-control/scripts/user_permission.ps1 b/skills/git-control/scripts/user_permission.ps1 new file mode 100644 index 0000000..e3b5863 --- /dev/null +++ b/skills/git-control/scripts/user_permission.ps1 @@ -0,0 +1,45 @@ +# Add, update, or revoke user permission on a repository + +param( + [string]$ApiToken = $env:GITEA_TOKEN, + [string]$GiteaBaseUrl = "http://afe.git:3000", + [string]$Owner, + [string]$Repo, + [string]$Username, + [ValidateSet("read", "write", "admin", "revoke")] + [string]$Action = "read" +) + +$ErrorActionPreference = "Stop" +$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json" +if (-not $ApiToken -and (Test-Path $ConfigPath)) { + $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json + $ApiToken = $cfg.ApiToken + if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl } +} +if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 } +if (-not $Owner -or -not $Repo -or -not $Username) { Write-Host "Error: Owner, Repo, Username required"; exit 1 } + +$ApiBase = "$GiteaBaseUrl/api/v1" +$ReposPath = "repos/$Owner/$Repo" +$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" } + +if ($Action -eq "revoke") { + try { + Invoke-RestMethod -Uri "$ApiBase/$ReposPath/collaborators/$Username" -Headers $Headers -Method Delete + Write-Host "Revoked $Username from $Owner/$Repo" + } catch { + if ($_.Exception.Response.StatusCode -eq 404) { Write-Host "User not a collaborator or repo not found" } + else { throw } + } + exit 0 +} + +$Body = @{ permission = $Action } | ConvertTo-Json +try { + Invoke-RestMethod -Uri "$ApiBase/$ReposPath/collaborators/$Username" -Headers $Headers -Method Put -Body $Body + Write-Host "Set $Username to $Action on $Owner/$Repo" +} catch { + Write-Host "FAIL: $($_.Exception.Message)" + exit 1 +}