fj/cmd/branch.go
sid 17ca49d0c5 feat: add branch, notification, org, open, whoami commands
Ports five commands from tea that fgj-sid was missing:

- fgj branch {list,rename,delete} — list branches with protection
  status, rename, and delete with confirmation.
- fgj notification {list,read} — list user notifications (unread by
  default, --all for everything), mark individual threads read.
- fgj org {list,create,delete} — manage organizations on the host.
  Create accepts --description/--full-name/--website/--location and
  --visibility (public/limited/private).
- fgj open [number] — open the repo, issue, or PR in a browser.
  Auto-detects issue-vs-PR via GetIssue. Falls back to printing the
  URL when stdout is not a TTY or --url is passed.
- fgj whoami — display authenticated user + host.

All commands follow the established pattern (parseRepo + config.Load +
api.NewClientFromConfig + ios), support --json where list semantics
apply, and share a new loadClient helper for host-scoped (non-repo)
commands. Tested live against forgejo.zerova.net.

Refs audit recommendation.md §'v0.5.0 — Missing resources'.
2026-04-19 21:27:55 -06:00

185 lines
4.6 KiB
Go

package cmd
import (
"fmt"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
var branchCmd = &cobra.Command{
Use: "branch",
Aliases: []string{"b"},
Short: "Manage repository branches",
Long: "List, rename, and delete branches in a repository.",
}
var branchListCmd = &cobra.Command{
Use: "list",
Short: "List repository branches",
Long: "List branches in a repository, showing protection status.",
Example: ` # List branches in the current repository
fgj branch list
# List branches in a specific repository
fgj branch list -R owner/repo
# Output as JSON
fgj branch list --json`,
RunE: runBranchList,
}
var branchRenameCmd = &cobra.Command{
Use: "rename <old-name> <new-name>",
Short: "Rename a branch",
Long: "Rename a branch in a repository. Requires Forgejo/Gitea support for branch rename (usually present).",
Example: ` # Rename a branch in the current repository
fgj branch rename old-name new-name
# Rename a branch in a specific repository
fgj branch rename main trunk -R owner/repo`,
Args: cobra.ExactArgs(2),
RunE: runBranchRename,
}
var branchDeleteCmd = &cobra.Command{
Use: "delete <name>",
Aliases: []string{"rm"},
Short: "Delete a branch",
Long: "Delete a branch from a repository. Protected branches cannot be deleted.",
Example: ` # Delete a branch
fgj branch delete feature/old-work
# Delete without confirmation
fgj branch delete feature/old-work -y`,
Args: cobra.ExactArgs(1),
RunE: runBranchDelete,
}
func init() {
rootCmd.AddCommand(branchCmd)
branchCmd.AddCommand(branchListCmd)
branchCmd.AddCommand(branchRenameCmd)
branchCmd.AddCommand(branchDeleteCmd)
addRepoFlags(branchListCmd)
addJSONFlags(branchListCmd, "Output as JSON")
addRepoFlags(branchRenameCmd)
addRepoFlags(branchDeleteCmd)
branchDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
func runBranchList(cmd *cobra.Command, args []string) error {
client, owner, name, err := newBranchClient(cmd)
if err != nil {
return err
}
branches, _, err := client.ListRepoBranches(owner, name, gitea.ListRepoBranchesOptions{})
if err != nil {
return fmt.Errorf("failed to list branches: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, branches)
}
if len(branches) == 0 {
fmt.Fprintln(ios.Out, "No branches found")
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("NAME", "PROTECTED", "COMMIT")
for _, b := range branches {
protected := ""
if b.Protected {
protected = "yes"
}
sha := ""
if b.Commit != nil {
if len(b.Commit.ID) >= 7 {
sha = b.Commit.ID[:7]
} else {
sha = b.Commit.ID
}
}
tp.AddRow(b.Name, protected, sha)
}
return tp.Render()
}
func runBranchRename(cmd *cobra.Command, args []string) error {
client, owner, name, err := newBranchClient(cmd)
if err != nil {
return err
}
oldName, newName := args[0], args[1]
_, _, err = client.UpdateRepoBranch(owner, name, oldName, gitea.UpdateRepoBranchOption{Name: newName})
if err != nil {
return fmt.Errorf("failed to rename branch %q to %q: %w", oldName, newName, err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Renamed branch %q to %q\n", cs.SuccessIcon(), oldName, newName)
return nil
}
func runBranchDelete(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("Delete 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
}
}
ok, _, err := client.DeleteRepoBranch(owner, name, branchName)
if err != nil {
return fmt.Errorf("failed to delete branch %q: %w", branchName, err)
}
if !ok {
return fmt.Errorf("branch %q was not deleted (it may be protected or not exist)", branchName)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Deleted branch %q\n", cs.SuccessIcon(), branchName)
return nil
}
func newBranchClient(cmd *cobra.Command) (*api.Client, string, string, error) {
repo, _ := cmd.Flags().GetString("repo")
owner, name, err := parseRepo(repo)
if err != nil {
return nil, "", "", err
}
cfg, err := config.Load()
if err != nil {
return nil, "", "", err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return nil, "", "", err
}
return client, owner, name, nil
}