cli/cmd/setup/github_protection.go
Charon c340303314 refactor: flatten commands, extract php/ci to own repos (#2)
## Summary
- Extract PHP/Laravel commands to `core/php` repo (42 files, standalone module)
- Extract CI/release + SDK commands to `core/ci` repo (10 files)
- Remove `internal/variants/` build tag system entirely
- Move all 30 remaining command packages from `internal/cmd/` to top-level `cmd/`
- Rewrite `main.go` with direct imports — no more variant selection
- PHP and CI are now optional via commented import lines in main.go

Co-authored-by: Claude <developers@lethean.io>
Reviewed-on: #2
Co-authored-by: Charon <charon@lthn.ai>
Co-committed-by: Charon <charon@lthn.ai>
2026-02-16 14:45:06 +00:00

299 lines
10 KiB
Go

// github_protection.go implements GitHub branch protection synchronization.
//
// Uses the gh api command for branch protection operations:
// - gh api repos/{owner}/{repo}/branches/{branch}/protection --method GET
// - gh api repos/{owner}/{repo}/branches/{branch}/protection --method PUT
package setup
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
)
// GitHubBranchProtection represents branch protection rules from the GitHub API.
type GitHubBranchProtection struct {
RequiredStatusChecks *RequiredStatusChecks `json:"required_status_checks"`
RequiredPullRequestReviews *RequiredPullRequestReviews `json:"required_pull_request_reviews"`
EnforceAdmins *EnforceAdmins `json:"enforce_admins"`
RequiredLinearHistory *RequiredLinearHistory `json:"required_linear_history"`
AllowForcePushes *AllowForcePushes `json:"allow_force_pushes"`
AllowDeletions *AllowDeletions `json:"allow_deletions"`
RequiredConversationResolution *RequiredConversationResolution `json:"required_conversation_resolution"`
}
// RequiredStatusChecks defines required CI checks.
type RequiredStatusChecks struct {
Strict bool `json:"strict"`
Contexts []string `json:"contexts"`
}
// RequiredPullRequestReviews defines review requirements.
type RequiredPullRequestReviews struct {
DismissStaleReviews bool `json:"dismiss_stale_reviews"`
RequireCodeOwnerReviews bool `json:"require_code_owner_reviews"`
RequiredApprovingReviewCount int `json:"required_approving_review_count"`
}
// EnforceAdmins indicates if admins are subject to rules.
type EnforceAdmins struct {
Enabled bool `json:"enabled"`
}
// RequiredLinearHistory indicates if linear history is required.
type RequiredLinearHistory struct {
Enabled bool `json:"enabled"`
}
// AllowForcePushes indicates if force pushes are allowed.
type AllowForcePushes struct {
Enabled bool `json:"enabled"`
}
// AllowDeletions indicates if branch deletion is allowed.
type AllowDeletions struct {
Enabled bool `json:"enabled"`
}
// RequiredConversationResolution indicates if conversation resolution is required.
type RequiredConversationResolution struct {
Enabled bool `json:"enabled"`
}
// GetBranchProtection fetches branch protection rules for a branch.
func GetBranchProtection(repoFullName, branch string) (*GitHubBranchProtection, error) {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
}
endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch)
cmd := exec.Command("gh", "api", endpoint)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
// Branch protection not enabled returns 404
if strings.Contains(stderr, "404") || strings.Contains(stderr, "Branch not protected") {
return nil, nil // No protection set
}
if strings.Contains(stderr, "403") {
return nil, cli.Err("insufficient permissions to manage branch protection (requires admin)")
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var protection GitHubBranchProtection
if err := json.Unmarshal(output, &protection); err != nil {
return nil, err
}
return &protection, nil
}
// SetBranchProtection sets branch protection rules for a branch.
func SetBranchProtection(repoFullName, branch string, config BranchProtectionConfig) error {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid repo format: %s", repoFullName)
}
// Build the protection payload
payload := map[string]interface{}{
"enforce_admins": config.EnforceAdmins,
"required_linear_history": config.RequireLinearHistory,
"allow_force_pushes": config.AllowForcePushes,
"allow_deletions": config.AllowDeletions,
"required_conversation_resolution": config.RequireConversationResolution,
}
// Required pull request reviews
if config.RequiredReviews > 0 {
payload["required_pull_request_reviews"] = map[string]interface{}{
"dismiss_stale_reviews": config.DismissStale,
"require_code_owner_reviews": config.RequireCodeOwnerReviews,
"required_approving_review_count": config.RequiredReviews,
}
} else {
payload["required_pull_request_reviews"] = nil
}
// Required status checks
if len(config.RequiredStatusChecks) > 0 {
payload["required_status_checks"] = map[string]interface{}{
"strict": true,
"contexts": config.RequiredStatusChecks,
}
} else {
payload["required_status_checks"] = nil
}
// Restrictions (required but can be empty for non-org repos)
payload["restrictions"] = nil
payloadJSON, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch)
cmd := exec.Command("gh", "api", endpoint, "--method", "PUT", "--input", "-")
cmd.Stdin = strings.NewReader(string(payloadJSON))
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", strings.TrimSpace(string(output)))
}
return nil
}
// SyncBranchProtection synchronizes branch protection for a repository.
func SyncBranchProtection(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) {
changes := NewChangeSet(repoFullName)
// Skip if no branch protection configured
if len(config.BranchProtection) == 0 {
return changes, nil
}
// Process each configured branch
for _, wantProtection := range config.BranchProtection {
branch := wantProtection.Branch
// Get existing protection
existing, err := GetBranchProtection(repoFullName, branch)
if err != nil {
// If permission denied, note it but don't fail
if strings.Contains(err.Error(), "insufficient permissions") {
changes.Add(CategoryProtection, ChangeSkip, branch, "insufficient permissions")
continue
}
return nil, cli.Wrap(err, "failed to get protection for "+branch)
}
// Check if protection needs to be created or updated
if existing == nil {
// Create new protection
changes.Add(CategoryProtection, ChangeCreate, branch, describeProtection(wantProtection))
if !dryRun {
if err := SetBranchProtection(repoFullName, branch, wantProtection); err != nil {
return changes, cli.Wrap(err, "failed to set protection for "+branch)
}
}
continue
}
// Compare and check if update is needed
needsUpdate := false
details := make(map[string]string)
// Check required reviews
existingReviews := 0
existingDismissStale := false
existingCodeOwner := false
if existing.RequiredPullRequestReviews != nil {
existingReviews = existing.RequiredPullRequestReviews.RequiredApprovingReviewCount
existingDismissStale = existing.RequiredPullRequestReviews.DismissStaleReviews
existingCodeOwner = existing.RequiredPullRequestReviews.RequireCodeOwnerReviews
}
if existingReviews != wantProtection.RequiredReviews {
needsUpdate = true
details["required_reviews"] = fmt.Sprintf("%d -> %d", existingReviews, wantProtection.RequiredReviews)
}
if existingDismissStale != wantProtection.DismissStale {
needsUpdate = true
details["dismiss_stale"] = fmt.Sprintf("%v -> %v", existingDismissStale, wantProtection.DismissStale)
}
if existingCodeOwner != wantProtection.RequireCodeOwnerReviews {
needsUpdate = true
details["code_owner_reviews"] = fmt.Sprintf("%v -> %v", existingCodeOwner, wantProtection.RequireCodeOwnerReviews)
}
// Check enforce admins
existingEnforceAdmins := false
if existing.EnforceAdmins != nil {
existingEnforceAdmins = existing.EnforceAdmins.Enabled
}
if existingEnforceAdmins != wantProtection.EnforceAdmins {
needsUpdate = true
details["enforce_admins"] = fmt.Sprintf("%v -> %v", existingEnforceAdmins, wantProtection.EnforceAdmins)
}
// Check linear history
existingLinear := false
if existing.RequiredLinearHistory != nil {
existingLinear = existing.RequiredLinearHistory.Enabled
}
if existingLinear != wantProtection.RequireLinearHistory {
needsUpdate = true
details["linear_history"] = fmt.Sprintf("%v -> %v", existingLinear, wantProtection.RequireLinearHistory)
}
// Check force pushes
existingForcePush := false
if existing.AllowForcePushes != nil {
existingForcePush = existing.AllowForcePushes.Enabled
}
if existingForcePush != wantProtection.AllowForcePushes {
needsUpdate = true
details["allow_force_pushes"] = fmt.Sprintf("%v -> %v", existingForcePush, wantProtection.AllowForcePushes)
}
// Check deletions
existingDeletions := false
if existing.AllowDeletions != nil {
existingDeletions = existing.AllowDeletions.Enabled
}
if existingDeletions != wantProtection.AllowDeletions {
needsUpdate = true
details["allow_deletions"] = fmt.Sprintf("%v -> %v", existingDeletions, wantProtection.AllowDeletions)
}
// Check required status checks
var existingStatusChecks []string
if existing.RequiredStatusChecks != nil {
existingStatusChecks = existing.RequiredStatusChecks.Contexts
}
if !stringSliceEqual(existingStatusChecks, wantProtection.RequiredStatusChecks) {
needsUpdate = true
details["status_checks"] = fmt.Sprintf("%v -> %v", existingStatusChecks, wantProtection.RequiredStatusChecks)
}
if needsUpdate {
changes.AddWithDetails(CategoryProtection, ChangeUpdate, branch, "", details)
if !dryRun {
if err := SetBranchProtection(repoFullName, branch, wantProtection); err != nil {
return changes, cli.Wrap(err, "failed to update protection for "+branch)
}
}
} else {
changes.Add(CategoryProtection, ChangeSkip, branch, "up to date")
}
}
return changes, nil
}
// describeProtection returns a human-readable description of protection rules.
func describeProtection(p BranchProtectionConfig) string {
var parts []string
if p.RequiredReviews > 0 {
parts = append(parts, fmt.Sprintf("%d review(s)", p.RequiredReviews))
}
if p.DismissStale {
parts = append(parts, "dismiss stale")
}
if p.EnforceAdmins {
parts = append(parts, "enforce admins")
}
if len(parts) == 0 {
return "basic protection"
}
return strings.Join(parts, ", ")
}