Five parallel tea-parity additions (~1100 LOC):
- fgj time {list,add,delete,reset} (aliases: times, t)
Tracked time entries. 'list' with no arg uses ListMyTrackedTimes
across all your repos; with an issue number uses ListIssueTrackedTimes.
'add' accepts Go duration strings (30m, 1h30m). 'delete' removes a
single entry by id; 'reset' clears all times on an issue. Confirmation
on delete/reset unless --yes or no TTY.
- fgj branch {protect,unprotect}
Branch protection rules. 'protect' idempotently creates-or-edits via
Get/Create/EditBranchProtection with --require-approvals,
--require-signed-commits, --dismiss-stale-approvals,
--block-on-rejected-reviews, --block-on-outdated-branch,
--push-whitelist, --merge-whitelist, --require-status-checks.
Empty whitelist flags leave existing rule fields untouched.
'unprotect' deletes; 404 is a friendly no-op.
- fgj release asset {list,create,delete} (alias: assets)
Granular attachment management. Resolves the release by tag or
"latest" using the existing helpers in cmd/release.go. 'create'
validates all paths up front then uploads each. 'delete' accepts
numeric ids OR filenames (cross-references the attachment list).
Per-asset confirmation unless --yes.
- fgj milestone issues {add,remove} (alias: i)
Associate/disassociate issues with a milestone. Milestone accepted
as title or numeric id (reuses resolveMilestone from milestone.go).
'remove' passes EditIssueOption{Milestone: &zero} — the Gitea/Forgejo
convention for clearing the association. Continues on per-issue
failure and exits 1 if any failed.
- fgj notification {unread,pin,unpin}
Complement the existing list/read. Factory pattern over
ReadNotification(id, NotifyStatus) with three distinct constants.
All five files are self-contained: each has its own init() attaching
to the existing parent cobra.Command (branchCmd, milestoneCmd,
notificationCmd, releaseCmd) without modifying any other file. Built
by parallel sub-agents; all compile, vet, and test clean.
204 lines
7 KiB
Go
204 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")
|
|
}
|