569 lines
15 KiB
Go
569 lines
15 KiB
Go
// Package cli parses command-line arguments for gh-repo-bootstrap.
|
|
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Action selects which Pulumi operation to perform.
|
|
type Action string
|
|
|
|
const (
|
|
ActionApply Action = "apply"
|
|
ActionPlan Action = "plan"
|
|
ActionDestroy Action = "destroy"
|
|
)
|
|
|
|
// BypassActor is a parsed --bypass spec.
|
|
type BypassActor struct {
|
|
ActorID int
|
|
ActorType string
|
|
BypassMode string
|
|
}
|
|
|
|
// Options holds the parsed CLI state.
|
|
type Options struct {
|
|
Owner string
|
|
Repo string
|
|
Branch string
|
|
Reviews int
|
|
Signed bool
|
|
Ruleset string
|
|
|
|
// Environments preserves insertion order. Lookups go through findEnv.
|
|
Environments []*EnvSpec
|
|
|
|
Bypass []BypassActor
|
|
Action Action
|
|
StateDir string
|
|
RepoSecretsFile string
|
|
EnvSecretsDir string
|
|
|
|
// Repo-level management.
|
|
RepoMode RepoMode // "" defaults to RepoModeData
|
|
RepoSettings RepoSettings
|
|
|
|
// ConfigFile, if set, means all other flags came from a TOML file.
|
|
ConfigFile string
|
|
}
|
|
|
|
const usage = `Usage:
|
|
gh repo-bootstrap <owner/repo> [options]
|
|
gh repo-bootstrap --config FILE
|
|
|
|
Apply a standard branch-protection ruleset, deployment environments,
|
|
optional environment protection rules, and optionally repo-level
|
|
settings to a GitHub repository via Pulumi.
|
|
|
|
Options:
|
|
--config FILE Load every setting from a TOML file. Exclusive
|
|
with every other flag (including <owner/repo>).
|
|
--branch NAME Default branch to protect (default: main)
|
|
--reviews N Required PR approving reviews (default: 1)
|
|
--signed Require signed commits on the protected branch
|
|
--env NAME Add a deployment environment (repeatable)
|
|
Default if none given: production
|
|
--ruleset NAME Ruleset name (default: default-branch-protection)
|
|
--bypass SPEC Add a bypass actor (repeatable). SPEC is
|
|
<actor_type>:<actor_id>[:<mode>]
|
|
--solo Shortcut for --bypass RepositoryRole:5:always
|
|
|
|
Repo-level (use with --create or --manage-repo):
|
|
--create Create the repo as a Pulumi resource. Runs minimal
|
|
interactive prompts for visibility + description
|
|
when those flags are not supplied.
|
|
--manage-repo Import an existing repo into Pulumi state and
|
|
manage its settings from now on. Run --plan first.
|
|
--visibility V Repo visibility: public | private
|
|
--description TEXT Repo description
|
|
--topic NAME Add a repository topic (repeatable)
|
|
--default-repo-branch NAME Default branch on the repo itself
|
|
(distinct from --branch which is the ruleset
|
|
target; on --create they default to the same).
|
|
--allow-merge-commit / --no-allow-merge-commit
|
|
--allow-squash-merge / --no-allow-squash-merge
|
|
--allow-rebase-merge / --no-allow-rebase-merge
|
|
--delete-branch-on-merge / --no-delete-branch-on-merge
|
|
--auto-init / --no-auto-init (--create only)
|
|
--license-template SLUG (--create only)
|
|
--gitignore-template NAME (--create only)
|
|
|
|
Environment protection:
|
|
--env-reviewer ENV:ACTOR (repeatable)
|
|
ACTOR is user:<id-or-login> or team:<id-or-slug>
|
|
where slug is org/team-slug
|
|
--env-wait-timer ENV:MINUTES
|
|
--env-prevent-self-review ENV
|
|
--env-no-admin-bypass ENV
|
|
--env-branch-policy ENV:MODE MODE = protected | custom | none
|
|
--env-branch-pattern ENV:PATTERN (repeatable)
|
|
|
|
Secrets:
|
|
--upload-repo-secrets FILE
|
|
--upload-env-secrets DIR
|
|
|
|
Run control:
|
|
--plan pulumi preview
|
|
--destroy pulumi destroy
|
|
--state-dir DIR Override working/state directory
|
|
(default: $XDG_STATE_HOME/gh-repo-bootstrap or
|
|
~/.local/state/gh-repo-bootstrap)
|
|
-h, --help Show this help
|
|
|
|
Authentication:
|
|
GITHUB_TOKEN is auto-populated from ` + "`gh auth token`" + ` if not already set.
|
|
`
|
|
|
|
// PrintUsage writes the usage text to stdout.
|
|
func PrintUsage() { fmt.Print(usage) }
|
|
|
|
// findEnv returns the EnvSpec with name n, creating it if necessary so order
|
|
// of first appearance is preserved.
|
|
func (o *Options) findEnv(n string) *EnvSpec {
|
|
for _, e := range o.Environments {
|
|
if e.Name == n {
|
|
return e
|
|
}
|
|
}
|
|
e := &EnvSpec{Name: n}
|
|
o.Environments = append(o.Environments, e)
|
|
return e
|
|
}
|
|
|
|
// Parse parses argv (excluding program name) into Options.
|
|
// Returns (nil, nil) when the user requested --help.
|
|
func Parse(argv []string) (*Options, error) {
|
|
// --- --config exclusivity check ---------------------------------------
|
|
if configFile, ok := findConfigFlag(argv); ok {
|
|
if len(argv) != 2 {
|
|
return nil, errors.New("No other flags allowed with config file.")
|
|
}
|
|
return &Options{ConfigFile: configFile}, nil
|
|
}
|
|
|
|
o := &Options{
|
|
Branch: "main",
|
|
Reviews: 1,
|
|
Ruleset: "default-branch-protection",
|
|
Action: ActionApply,
|
|
}
|
|
|
|
need := func(i int, flag string) (string, error) {
|
|
if i+1 >= len(argv) {
|
|
return "", fmt.Errorf("%s requires a value", flag)
|
|
}
|
|
return argv[i+1], nil
|
|
}
|
|
|
|
bptr := func(b bool) *bool { return &b }
|
|
|
|
i := 0
|
|
for i < len(argv) {
|
|
a := argv[i]
|
|
switch a {
|
|
case "-h", "--help":
|
|
PrintUsage()
|
|
return nil, nil
|
|
case "--branch":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.Branch = v
|
|
i += 2
|
|
case "--reviews":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
return nil, fmt.Errorf("--reviews must be a non-negative integer (got: %s)", v)
|
|
}
|
|
o.Reviews = n
|
|
i += 2
|
|
case "--signed":
|
|
o.Signed = true
|
|
i++
|
|
case "--ruleset":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.Ruleset = v
|
|
i += 2
|
|
case "--env":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = o.findEnv(v)
|
|
i += 2
|
|
case "--bypass":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := parseBypass(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.Bypass = append(o.Bypass, b)
|
|
i += 2
|
|
case "--solo":
|
|
o.Bypass = append(o.Bypass, BypassActor{ActorID: 5, ActorType: "RepositoryRole", BypassMode: "always"})
|
|
i++
|
|
case "--upload-repo-secrets":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.RepoSecretsFile = v
|
|
i += 2
|
|
case "--upload-env-secrets":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.EnvSecretsDir = v
|
|
i += 2
|
|
case "--plan":
|
|
o.Action = ActionPlan
|
|
i++
|
|
case "--destroy":
|
|
o.Action = ActionDestroy
|
|
i++
|
|
case "--state-dir":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.StateDir = v
|
|
i += 2
|
|
|
|
// --- Repo-level management ---------------------------------------
|
|
case "--create":
|
|
if o.RepoMode == RepoModeManage {
|
|
return nil, errors.New("--create and --manage-repo are mutually exclusive")
|
|
}
|
|
o.RepoMode = RepoModeCreate
|
|
i++
|
|
case "--manage-repo":
|
|
if o.RepoMode == RepoModeCreate {
|
|
return nil, errors.New("--create and --manage-repo are mutually exclusive")
|
|
}
|
|
o.RepoMode = RepoModeManage
|
|
i++
|
|
case "--visibility":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if v != "public" && v != "private" {
|
|
return nil, fmt.Errorf("--visibility must be public or private (got: %s)", v)
|
|
}
|
|
o.RepoSettings.Visibility = v
|
|
i += 2
|
|
case "--description":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := v
|
|
o.RepoSettings.Description = &s
|
|
i += 2
|
|
case "--topic":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.RepoSettings.Topics = append(o.RepoSettings.Topics, v)
|
|
i += 2
|
|
case "--default-repo-branch":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.RepoSettings.DefaultBranch = v
|
|
i += 2
|
|
case "--allow-merge-commit":
|
|
o.RepoSettings.AllowMergeCommit = bptr(true)
|
|
i++
|
|
case "--no-allow-merge-commit":
|
|
o.RepoSettings.AllowMergeCommit = bptr(false)
|
|
i++
|
|
case "--allow-squash-merge":
|
|
o.RepoSettings.AllowSquashMerge = bptr(true)
|
|
i++
|
|
case "--no-allow-squash-merge":
|
|
o.RepoSettings.AllowSquashMerge = bptr(false)
|
|
i++
|
|
case "--allow-rebase-merge":
|
|
o.RepoSettings.AllowRebaseMerge = bptr(true)
|
|
i++
|
|
case "--no-allow-rebase-merge":
|
|
o.RepoSettings.AllowRebaseMerge = bptr(false)
|
|
i++
|
|
case "--delete-branch-on-merge":
|
|
o.RepoSettings.DeleteBranchOnMerge = bptr(true)
|
|
i++
|
|
case "--no-delete-branch-on-merge":
|
|
o.RepoSettings.DeleteBranchOnMerge = bptr(false)
|
|
i++
|
|
case "--auto-init":
|
|
o.RepoSettings.AutoInit = bptr(true)
|
|
i++
|
|
case "--no-auto-init":
|
|
o.RepoSettings.AutoInit = bptr(false)
|
|
i++
|
|
case "--license-template":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.RepoSettings.LicenseTemplate = v
|
|
i += 2
|
|
case "--gitignore-template":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.RepoSettings.GitignoreTemplate = v
|
|
i += 2
|
|
|
|
// --- Environment protection --------------------------------------
|
|
case "--env-reviewer":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := parseEnvReviewer(o, v); err != nil {
|
|
return nil, err
|
|
}
|
|
i += 2
|
|
case "--env-wait-timer":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
envName, rest, ok := splitColon(v)
|
|
if !ok {
|
|
return nil, fmt.Errorf("--env-wait-timer expects ENV:MINUTES (got: %s)", v)
|
|
}
|
|
n, err := strconv.Atoi(rest)
|
|
if err != nil || n < 0 {
|
|
return nil, fmt.Errorf("--env-wait-timer minutes must be a non-negative integer (got: %s)", rest)
|
|
}
|
|
e := o.findEnv(envName)
|
|
e.WaitTimer = &n
|
|
i += 2
|
|
case "--env-prevent-self-review":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e := o.findEnv(v)
|
|
e.PreventSelfReview = bptr(true)
|
|
i += 2
|
|
case "--env-no-admin-bypass":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e := o.findEnv(v)
|
|
e.CanAdminsBypass = bptr(false)
|
|
i += 2
|
|
case "--env-branch-policy":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
envName, mode, ok := splitColon(v)
|
|
if !ok {
|
|
return nil, fmt.Errorf("--env-branch-policy expects ENV:MODE (got: %s)", v)
|
|
}
|
|
switch mode {
|
|
case "none", "protected", "custom":
|
|
default:
|
|
return nil, fmt.Errorf("--env-branch-policy MODE must be none|protected|custom (got: %s)", mode)
|
|
}
|
|
e := o.findEnv(envName)
|
|
e.BranchPolicy = mode
|
|
i += 2
|
|
case "--env-branch-pattern":
|
|
v, err := need(i, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
envName, pat, ok := splitColon(v)
|
|
if !ok || pat == "" {
|
|
return nil, fmt.Errorf("--env-branch-pattern expects ENV:PATTERN (got: %s)", v)
|
|
}
|
|
e := o.findEnv(envName)
|
|
e.BranchPatterns = append(e.BranchPatterns, pat)
|
|
i += 2
|
|
|
|
case "--":
|
|
i++
|
|
for ; i < len(argv); i++ {
|
|
if err := o.setRepo(argv[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
default:
|
|
if strings.HasPrefix(a, "-") {
|
|
return nil, fmt.Errorf("unknown option: %s", a)
|
|
}
|
|
if err := o.setRepo(a); err != nil {
|
|
return nil, err
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
|
|
if o.Repo == "" {
|
|
return nil, errors.New("first argument must be <owner>/<repo>")
|
|
}
|
|
if len(o.Environments) == 0 {
|
|
o.Environments = []*EnvSpec{{Name: "production"}}
|
|
}
|
|
if o.RepoMode == "" {
|
|
o.RepoMode = RepoModeData
|
|
}
|
|
if err := validateRepoModeFlags(o); err != nil {
|
|
return nil, err
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// findConfigFlag scans argv for `--config FILE` or `--config=FILE`.
|
|
func findConfigFlag(argv []string) (string, bool) {
|
|
for i, a := range argv {
|
|
if a == "--config" {
|
|
if i+1 < len(argv) {
|
|
return argv[i+1], true
|
|
}
|
|
return "", true // present but missing; caller will fail exclusivity check
|
|
}
|
|
if strings.HasPrefix(a, "--config=") {
|
|
return strings.TrimPrefix(a, "--config="), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// validateRepoModeFlags rejects repo-settings flags when mode is "data".
|
|
func validateRepoModeFlags(o *Options) error {
|
|
if o.RepoMode != RepoModeData {
|
|
return nil
|
|
}
|
|
rs := o.RepoSettings
|
|
if rs.Visibility != "" || rs.Description != nil || rs.DefaultBranch != "" ||
|
|
len(rs.Topics) > 0 ||
|
|
rs.AllowMergeCommit != nil || rs.AllowSquashMerge != nil ||
|
|
rs.AllowRebaseMerge != nil || rs.DeleteBranchOnMerge != nil ||
|
|
rs.AutoInit != nil || rs.LicenseTemplate != "" || rs.GitignoreTemplate != "" {
|
|
return errors.New("repo-level settings require --create or --manage-repo")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseEnvReviewer parses `ENV:user:NAME-OR-ID` or `ENV:team:SLUG-OR-ID`.
|
|
func parseEnvReviewer(o *Options, spec string) error {
|
|
envName, rest, ok := splitColon(spec)
|
|
if !ok {
|
|
return fmt.Errorf("--env-reviewer expects ENV:user:... or ENV:team:... (got: %s)", spec)
|
|
}
|
|
kind, who, ok := splitColon(rest)
|
|
if !ok || who == "" {
|
|
return fmt.Errorf("--env-reviewer expects ENV:user:... or ENV:team:... (got: %s)", spec)
|
|
}
|
|
e := o.findEnv(envName)
|
|
switch kind {
|
|
case "user":
|
|
e.ReviewerUsers = append(e.ReviewerUsers, who)
|
|
case "team":
|
|
e.ReviewerTeams = append(e.ReviewerTeams, who)
|
|
default:
|
|
return fmt.Errorf("--env-reviewer kind must be user or team (got: %s)", kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitColon splits "a:b" into ("a", "b", true). For "a:b:c" returns ("a", "b:c", true).
|
|
func splitColon(s string) (string, string, bool) {
|
|
idx := strings.IndexByte(s, ':')
|
|
if idx <= 0 || idx == len(s)-1 {
|
|
return "", "", false
|
|
}
|
|
return s[:idx], s[idx+1:], true
|
|
}
|
|
|
|
func (o *Options) setRepo(a string) error {
|
|
if o.Repo != "" {
|
|
return fmt.Errorf("unexpected positional argument: %s", a)
|
|
}
|
|
slash := strings.IndexByte(a, '/')
|
|
if slash <= 0 || slash == len(a)-1 {
|
|
return errors.New("first argument must be <owner>/<repo>")
|
|
}
|
|
o.Owner = a[:slash]
|
|
o.Repo = a[slash+1:]
|
|
return nil
|
|
}
|
|
|
|
func parseBypass(spec string) (BypassActor, error) {
|
|
parts := strings.Split(spec, ":")
|
|
if len(parts) < 2 || len(parts) > 3 {
|
|
return BypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected <actor_type>:<actor_id>[:<mode>])", spec)
|
|
}
|
|
actorType, actorIDStr := parts[0], parts[1]
|
|
mode := "always"
|
|
if len(parts) == 3 {
|
|
mode = parts[2]
|
|
}
|
|
if actorType == "" || actorIDStr == "" {
|
|
return BypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected <actor_type>:<actor_id>[:<mode>])", spec)
|
|
}
|
|
id, err := strconv.Atoi(actorIDStr)
|
|
if err != nil || id < 0 {
|
|
return BypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (actor_id must be numeric)", spec)
|
|
}
|
|
switch mode {
|
|
case "always", "pull_request":
|
|
default:
|
|
return BypassActor{}, fmt.Errorf("invalid --bypass mode %q (must be 'always' or 'pull_request')", mode)
|
|
}
|
|
switch actorType {
|
|
case "RepositoryRole", "Team", "Integration", "OrganizationAdmin", "DeployKey":
|
|
default:
|
|
return BypassActor{}, fmt.Errorf("invalid --bypass actor_type %q", actorType)
|
|
}
|
|
return BypassActor{ActorID: id, ActorType: actorType, BypassMode: mode}, nil
|
|
}
|
|
|
|
// DefaultStateDir returns the per-repo state directory under
|
|
// $XDG_STATE_HOME/gh-repo-bootstrap or ~/.local/state/gh-repo-bootstrap.
|
|
func DefaultStateDir(owner, repo string) string {
|
|
base := os.Getenv("XDG_STATE_HOME")
|
|
if base == "" {
|
|
home, _ := os.UserHomeDir()
|
|
base = home + "/.local/state"
|
|
}
|
|
return fmt.Sprintf("%s/gh-repo-bootstrap/%s__%s", base, owner, repo)
|
|
}
|
|
|
|
// EnvNames returns the list of environment names in insertion order, for
|
|
// secret-file matching and other places that don't need the full EnvSpec.
|
|
func (o *Options) EnvNames() []string {
|
|
out := make([]string, len(o.Environments))
|
|
for i, e := range o.Environments {
|
|
out[i] = e.Name
|
|
}
|
|
return out
|
|
}
|