205 lines
7 KiB
Go
205 lines
7 KiB
Go
|
|
package cmd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"code.gitea.io/sdk/gitea"
|
||
|
|
"github.com/spf13/cobra"
|
||
|
|
)
|
||
|
|
|
||
|
|
var branchProtectCmd = &cobra.Command{
|
||
|
|
Use: "protect <branch-name>",
|
||
|
|
Short: "Protect a branch",
|
||
|
|
Long: `Create or update a branch protection rule for the given branch.
|
||
|
|
|
||
|
|
If a protection rule already exists for the branch it is updated in place;
|
||
|
|
otherwise a new one is created. Fields you do not set on the command line
|
||
|
|
are left at their server-side defaults (or left unchanged when editing).`,
|
||
|
|
Example: ` # Require 2 approving reviews before merging
|
||
|
|
fgj branch protect main --require-approvals 2
|
||
|
|
|
||
|
|
# Dismiss stale approvals and require signed commits
|
||
|
|
fgj branch protect main --dismiss-stale-approvals --require-signed-commits
|
||
|
|
|
||
|
|
# Allow specific users to push directly, and require CI contexts
|
||
|
|
fgj branch protect main \
|
||
|
|
--push-whitelist alice,bob \
|
||
|
|
--require-status-checks ci/build,ci/test`,
|
||
|
|
Args: cobra.ExactArgs(1),
|
||
|
|
RunE: runBranchProtect,
|
||
|
|
}
|
||
|
|
|
||
|
|
var branchUnprotectCmd = &cobra.Command{
|
||
|
|
Use: "unprotect <branch-name>",
|
||
|
|
Short: "Remove a branch protection rule",
|
||
|
|
Long: "Remove the protection rule attached to a branch. If the branch has no protection this is a no-op.",
|
||
|
|
Example: ` # Remove protection interactively
|
||
|
|
fgj branch unprotect main
|
||
|
|
|
||
|
|
# Remove without confirmation
|
||
|
|
fgj branch unprotect main -y`,
|
||
|
|
Args: cobra.ExactArgs(1),
|
||
|
|
RunE: runBranchUnprotect,
|
||
|
|
}
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
branchCmd.AddCommand(branchProtectCmd)
|
||
|
|
branchCmd.AddCommand(branchUnprotectCmd)
|
||
|
|
|
||
|
|
addRepoFlags(branchProtectCmd)
|
||
|
|
branchProtectCmd.Flags().Int64("require-approvals", 0, "Minimum number of approving reviews required")
|
||
|
|
branchProtectCmd.Flags().Bool("dismiss-stale-approvals", false, "Dismiss stale approvals when new commits are pushed")
|
||
|
|
branchProtectCmd.Flags().Bool("require-signed-commits", false, "Require commits on the branch to be signed")
|
||
|
|
branchProtectCmd.Flags().Bool("block-on-rejected-reviews", false, "Block merges when a review requests changes")
|
||
|
|
branchProtectCmd.Flags().Bool("block-on-outdated-branch", false, "Require the PR branch to be up-to-date with the base")
|
||
|
|
branchProtectCmd.Flags().StringSlice("push-whitelist", nil, "Usernames allowed to push directly (comma-separated or repeatable)")
|
||
|
|
branchProtectCmd.Flags().StringSlice("merge-whitelist", nil, "Usernames allowed to merge (comma-separated or repeatable)")
|
||
|
|
branchProtectCmd.Flags().StringSlice("require-status-checks", nil, "CI status contexts that must pass (comma-separated or repeatable)")
|
||
|
|
addJSONFlags(branchProtectCmd, "Output the resulting protection rule as JSON")
|
||
|
|
|
||
|
|
addRepoFlags(branchUnprotectCmd)
|
||
|
|
branchUnprotectCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
||
|
|
}
|
||
|
|
|
||
|
|
func runBranchProtect(cmd *cobra.Command, args []string) error {
|
||
|
|
client, owner, name, err := newBranchClient(cmd)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
branchName := args[0]
|
||
|
|
|
||
|
|
requireApprovals, _ := cmd.Flags().GetInt64("require-approvals")
|
||
|
|
dismissStale, _ := cmd.Flags().GetBool("dismiss-stale-approvals")
|
||
|
|
requireSigned, _ := cmd.Flags().GetBool("require-signed-commits")
|
||
|
|
blockRejected, _ := cmd.Flags().GetBool("block-on-rejected-reviews")
|
||
|
|
blockOutdated, _ := cmd.Flags().GetBool("block-on-outdated-branch")
|
||
|
|
pushWhitelist, _ := cmd.Flags().GetStringSlice("push-whitelist")
|
||
|
|
mergeWhitelist, _ := cmd.Flags().GetStringSlice("merge-whitelist")
|
||
|
|
statusChecks, _ := cmd.Flags().GetStringSlice("require-status-checks")
|
||
|
|
|
||
|
|
// Check whether a protection rule already exists for this branch.
|
||
|
|
existing, resp, getErr := client.GetBranchProtection(owner, name, branchName)
|
||
|
|
exists := getErr == nil && existing != nil
|
||
|
|
if getErr != nil && !isNotFound(resp, getErr) {
|
||
|
|
return fmt.Errorf("failed to look up branch protection for %q: %w", branchName, getErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
var result *gitea.BranchProtection
|
||
|
|
|
||
|
|
if exists {
|
||
|
|
edit := gitea.EditBranchProtectionOption{
|
||
|
|
RequiredApprovals: &requireApprovals,
|
||
|
|
DismissStaleApprovals: &dismissStale,
|
||
|
|
RequireSignedCommits: &requireSigned,
|
||
|
|
BlockOnRejectedReviews: &blockRejected,
|
||
|
|
BlockOnOutdatedBranch: &blockOutdated,
|
||
|
|
}
|
||
|
|
if len(pushWhitelist) > 0 {
|
||
|
|
enable := true
|
||
|
|
edit.EnablePushWhitelist = &enable
|
||
|
|
edit.PushWhitelistUsernames = pushWhitelist
|
||
|
|
}
|
||
|
|
if len(mergeWhitelist) > 0 {
|
||
|
|
enable := true
|
||
|
|
edit.EnableMergeWhitelist = &enable
|
||
|
|
edit.MergeWhitelistUsernames = mergeWhitelist
|
||
|
|
}
|
||
|
|
if len(statusChecks) > 0 {
|
||
|
|
enable := true
|
||
|
|
edit.EnableStatusCheck = &enable
|
||
|
|
edit.StatusCheckContexts = statusChecks
|
||
|
|
}
|
||
|
|
|
||
|
|
bp, _, err := client.EditBranchProtection(owner, name, branchName, edit)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to update branch protection for %q: %w", branchName, err)
|
||
|
|
}
|
||
|
|
result = bp
|
||
|
|
} else {
|
||
|
|
create := gitea.CreateBranchProtectionOption{
|
||
|
|
BranchName: branchName,
|
||
|
|
RequiredApprovals: requireApprovals,
|
||
|
|
DismissStaleApprovals: dismissStale,
|
||
|
|
RequireSignedCommits: requireSigned,
|
||
|
|
BlockOnRejectedReviews: blockRejected,
|
||
|
|
BlockOnOutdatedBranch: blockOutdated,
|
||
|
|
}
|
||
|
|
if len(pushWhitelist) > 0 {
|
||
|
|
create.EnablePushWhitelist = true
|
||
|
|
create.PushWhitelistUsernames = pushWhitelist
|
||
|
|
}
|
||
|
|
if len(mergeWhitelist) > 0 {
|
||
|
|
create.EnableMergeWhitelist = true
|
||
|
|
create.MergeWhitelistUsernames = mergeWhitelist
|
||
|
|
}
|
||
|
|
if len(statusChecks) > 0 {
|
||
|
|
create.EnableStatusCheck = true
|
||
|
|
create.StatusCheckContexts = statusChecks
|
||
|
|
}
|
||
|
|
|
||
|
|
bp, _, err := client.CreateBranchProtection(owner, name, create)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to create branch protection for %q: %w", branchName, err)
|
||
|
|
}
|
||
|
|
result = bp
|
||
|
|
}
|
||
|
|
|
||
|
|
if wantJSON(cmd) {
|
||
|
|
return outputJSON(cmd, result)
|
||
|
|
}
|
||
|
|
|
||
|
|
cs := ios.ColorScheme()
|
||
|
|
fmt.Fprintf(ios.Out, "%s Protected branch %q\n", cs.SuccessIcon(), branchName)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func runBranchUnprotect(cmd *cobra.Command, args []string) error {
|
||
|
|
client, owner, name, err := newBranchClient(cmd)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
branchName := args[0]
|
||
|
|
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||
|
|
|
||
|
|
if !skipConfirm && ios.IsStdinTTY() {
|
||
|
|
answer, err := promptLine(fmt.Sprintf("Remove protection from branch %q in %s/%s? [y/N]: ", branchName, owner, name))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if answer != "y" && answer != "Y" && answer != "yes" {
|
||
|
|
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := client.DeleteBranchProtection(owner, name, branchName)
|
||
|
|
if err != nil {
|
||
|
|
if isNotFound(resp, err) {
|
||
|
|
fmt.Fprintf(ios.Out, "Branch %q has no protection rule; nothing to do.\n", branchName)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return fmt.Errorf("failed to remove branch protection for %q: %w", branchName, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
cs := ios.ColorScheme()
|
||
|
|
fmt.Fprintf(ios.Out, "%s Removed protection from branch %q\n", cs.SuccessIcon(), branchName)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// isNotFound reports whether a gitea SDK call failed with a 404. The SDK
|
||
|
|
// sometimes returns a nil Response on transport-level errors, so we fall back
|
||
|
|
// to a string check on the error message in that case.
|
||
|
|
func isNotFound(resp *gitea.Response, err error) bool {
|
||
|
|
if err == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return strings.Contains(err.Error(), "404")
|
||
|
|
}
|