Files
gh-repo-bootstrap/internal/cli/toml.go
T

327 lines
9.3 KiB
Go

package cli
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
// configFile is the on-disk schema for `--config FILE`. The loader translates
// it into an *Options that downstream code consumes identically to flag-based
// invocations.
type configFile struct {
Owner string `toml:"owner"`
Name string `toml:"name"`
Mode string `toml:"mode"`
StateDir string `toml:"state_dir"`
Action string `toml:"action"`
Repo *configRepo `toml:"repo"`
Ruleset *configRuleset `toml:"ruleset"`
Environments []configEnv `toml:"environments"`
Secrets *configSecrets `toml:"secrets"`
}
type configRepo struct {
Visibility string `toml:"visibility"`
Description *string `toml:"description"`
DefaultBranch string `toml:"default_branch"`
Topics []string `toml:"topics"`
AllowMergeCommit *bool `toml:"allow_merge_commit"`
AllowSquashMerge *bool `toml:"allow_squash_merge"`
AllowRebaseMerge *bool `toml:"allow_rebase_merge"`
DeleteBranchOnMerge *bool `toml:"delete_branch_on_merge"`
AutoInit *bool `toml:"auto_init"`
LicenseTemplate string `toml:"license_template"`
GitignoreTemplate string `toml:"gitignore_template"`
}
type configRuleset struct {
Name string `toml:"name"`
Branch string `toml:"branch"`
RequiredReviews *int `toml:"required_reviews"`
RequireSignedCommits *bool `toml:"require_signed_commits"`
Bypass []configBypass `toml:"bypass"`
}
type configBypass struct {
ActorType string `toml:"actor_type"`
ActorID int `toml:"actor_id"`
Mode string `toml:"mode"`
}
type configEnv struct {
Name string `toml:"name"`
WaitTimer *int `toml:"wait_timer"`
PreventSelfReview *bool `toml:"prevent_self_review"`
CanAdminsBypass *bool `toml:"can_admins_bypass"`
ReviewersUsers []any `toml:"reviewers_users"`
ReviewersTeams []any `toml:"reviewers_teams"`
BranchPolicy string `toml:"branch_policy"`
BranchPatterns []string `toml:"branch_patterns"`
}
type configSecrets struct {
RepoFile string `toml:"repo_file"`
EnvDir string `toml:"env_dir"`
}
// LoadConfig reads the TOML file at path and returns a fully-populated
// *Options. It validates create-mode required fields and rejects unknown
// values up front.
func LoadConfig(path string) (*Options, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cf configFile
md, err := toml.Decode(string(raw), &cf)
if err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if undec := md.Undecoded(); len(undec) > 0 {
return nil, fmt.Errorf("unknown key in config: %s", undec[0])
}
if cf.Owner == "" {
return nil, fmt.Errorf("config: owner is required")
}
if cf.Name == "" {
return nil, fmt.Errorf("config: name is required")
}
mode := RepoMode(cf.Mode)
if mode == "" {
mode = RepoModeData
}
switch mode {
case RepoModeData, RepoModeCreate, RepoModeManage:
default:
return nil, fmt.Errorf("config: mode must be data|create|manage (got: %s)", cf.Mode)
}
action := ActionApply
switch cf.Action {
case "", "apply":
action = ActionApply
case "plan":
action = ActionPlan
case "destroy":
action = ActionDestroy
default:
return nil, fmt.Errorf("config: action must be apply|plan|destroy (got: %s)", cf.Action)
}
o := &Options{
Owner: cf.Owner,
Repo: cf.Name,
Action: action,
StateDir: cf.StateDir,
RepoMode: mode,
Branch: "main",
Reviews: 1,
Ruleset: "default-branch-protection",
}
// --- [repo] -----------------------------------------------------------
if cf.Repo != nil {
r := cf.Repo
if r.Visibility != "" && r.Visibility != "public" && r.Visibility != "private" {
return nil, fmt.Errorf("config: [repo].visibility must be public or private (got: %s)", r.Visibility)
}
o.RepoSettings = RepoSettings{
Visibility: r.Visibility,
Description: r.Description,
DefaultBranch: r.DefaultBranch,
Topics: r.Topics,
AllowMergeCommit: r.AllowMergeCommit,
AllowSquashMerge: r.AllowSquashMerge,
AllowRebaseMerge: r.AllowRebaseMerge,
DeleteBranchOnMerge: r.DeleteBranchOnMerge,
AutoInit: r.AutoInit,
LicenseTemplate: r.LicenseTemplate,
GitignoreTemplate: r.GitignoreTemplate,
}
}
if mode == RepoModeCreate {
if err := validateCreateRequired(cf.Repo); err != nil {
return nil, err
}
}
if mode == RepoModeManage {
if err := validateManageRequired(cf.Repo); err != nil {
return nil, err
}
}
// --- [ruleset] --------------------------------------------------------
if cf.Ruleset != nil {
if cf.Ruleset.Name != "" {
o.Ruleset = cf.Ruleset.Name
}
if cf.Ruleset.Branch != "" {
o.Branch = cf.Ruleset.Branch
}
if cf.Ruleset.RequiredReviews != nil {
if *cf.Ruleset.RequiredReviews < 0 {
return nil, fmt.Errorf("config: [ruleset].required_reviews must be >= 0")
}
o.Reviews = *cf.Ruleset.RequiredReviews
}
if cf.Ruleset.RequireSignedCommits != nil {
o.Signed = *cf.Ruleset.RequireSignedCommits
}
for _, b := range cf.Ruleset.Bypass {
mode := b.Mode
if mode == "" {
mode = "always"
}
if mode != "always" && mode != "pull_request" {
return nil, fmt.Errorf("config: ruleset bypass mode must be always|pull_request (got: %s)", b.Mode)
}
if b.ActorType == "" || b.ActorID == 0 {
return nil, fmt.Errorf("config: ruleset bypass entry requires actor_type and actor_id")
}
o.Bypass = append(o.Bypass, BypassActor{
ActorID: b.ActorID,
ActorType: b.ActorType,
BypassMode: mode,
})
}
}
// --- [[environments]] ------------------------------------------------
for idx, e := range cf.Environments {
if e.Name == "" {
return nil, fmt.Errorf("config: [[environments]][%d].name is required", idx)
}
spec := &EnvSpec{
Name: e.Name,
WaitTimer: e.WaitTimer,
PreventSelfReview: e.PreventSelfReview,
CanAdminsBypass: e.CanAdminsBypass,
}
for _, v := range e.ReviewersUsers {
s, err := anyToReviewerStr(v)
if err != nil {
return nil, fmt.Errorf("config: env %q reviewers_users: %w", e.Name, err)
}
spec.ReviewerUsers = append(spec.ReviewerUsers, s)
}
for _, v := range e.ReviewersTeams {
s, err := anyToReviewerStr(v)
if err != nil {
return nil, fmt.Errorf("config: env %q reviewers_teams: %w", e.Name, err)
}
spec.ReviewerTeams = append(spec.ReviewerTeams, s)
}
switch e.BranchPolicy {
case "", "none", "protected", "custom":
default:
return nil, fmt.Errorf("config: env %q branch_policy must be none|protected|custom (got: %s)", e.Name, e.BranchPolicy)
}
spec.BranchPolicy = e.BranchPolicy
spec.BranchPatterns = e.BranchPatterns
o.Environments = append(o.Environments, spec)
}
if len(o.Environments) == 0 {
o.Environments = []*EnvSpec{{Name: "production"}}
}
// --- [secrets] -------------------------------------------------------
if cf.Secrets != nil {
o.RepoSecretsFile = cf.Secrets.RepoFile
o.EnvSecretsDir = cf.Secrets.EnvDir
}
return o, nil
}
// anyToReviewerStr accepts either a TOML integer or string and renders it as
// the raw reviewer identifier that the resolver will later turn into an int.
func anyToReviewerStr(v any) (string, error) {
switch t := v.(type) {
case string:
return t, nil
case int64:
return fmt.Sprintf("%d", t), nil
case int:
return fmt.Sprintf("%d", t), nil
default:
return "", fmt.Errorf("reviewer entries must be strings or integers (got: %T)", v)
}
}
// validateCreateRequired checks every required key for mode = "create".
func validateCreateRequired(r *configRepo) error {
if r == nil {
return fmt.Errorf("config: [repo] table is required when mode = \"create\"")
}
missing := func(name string) error {
return fmt.Errorf("config: [repo].%s is required when mode = \"create\"", name)
}
if r.Visibility == "" {
return missing("visibility")
}
if r.Description == nil {
return missing("description")
}
if r.DefaultBranch == "" {
return missing("default_branch")
}
if r.AllowMergeCommit == nil {
return missing("allow_merge_commit")
}
if r.AllowSquashMerge == nil {
return missing("allow_squash_merge")
}
if r.AllowRebaseMerge == nil {
return missing("allow_rebase_merge")
}
if r.DeleteBranchOnMerge == nil {
return missing("delete_branch_on_merge")
}
if r.AutoInit == nil {
return missing("auto_init")
}
return nil
}
// validateManageRequired checks every required key for mode = "manage".
// auto_init / license_template / gitignore_template apply only at creation
// time and are ignored here.
func validateManageRequired(r *configRepo) error {
if r == nil {
return fmt.Errorf("config: [repo] table is required when mode = \"manage\"")
}
missing := func(name string) error {
return fmt.Errorf("config: [repo].%s is required when mode = \"manage\"", name)
}
if r.Visibility == "" {
return missing("visibility")
}
if r.Description == nil {
return missing("description")
}
if r.DefaultBranch == "" {
return missing("default_branch")
}
if r.AllowMergeCommit == nil {
return missing("allow_merge_commit")
}
if r.AllowSquashMerge == nil {
return missing("allow_squash_merge")
}
if r.AllowRebaseMerge == nil {
return missing("allow_rebase_merge")
}
if r.DeleteBranchOnMerge == nil {
return missing("delete_branch_on_merge")
}
return nil
}