diff --git a/cmd/branch.go b/cmd/branch.go new file mode 100644 index 0000000..a6453af --- /dev/null +++ b/cmd/branch.go @@ -0,0 +1,185 @@ +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 ", + 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 ", + 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 +} diff --git a/cmd/notification.go b/cmd/notification.go new file mode 100644 index 0000000..bd245cf --- /dev/null +++ b/cmd/notification.go @@ -0,0 +1,137 @@ +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 notificationCmd = &cobra.Command{ + Use: "notification", + Aliases: []string{"notifications", "n"}, + Short: "Manage user notifications", + Long: "List and mark notifications for the authenticated user.", +} + +var notificationListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List notifications", + Long: "List notifications for the authenticated user. Shows unread by default.", + Example: ` # List unread notifications + fgj notification list + + # Include read and pinned notifications + fgj notification list --all + + # Limit number of results + fgj notification list -L 50 + + # Output as JSON + fgj notification list --json`, + RunE: runNotificationList, +} + +var notificationReadCmd = &cobra.Command{ + Use: "read ", + Aliases: []string{"r"}, + Short: "Mark a notification as read", + Long: "Mark a single notification thread as read by its ID.", + Args: cobra.ExactArgs(1), + RunE: runNotificationRead, +} + +func init() { + rootCmd.AddCommand(notificationCmd) + notificationCmd.AddCommand(notificationListCmd) + notificationCmd.AddCommand(notificationReadCmd) + + notificationListCmd.Flags().Bool("all", false, "Include read and pinned notifications (not just unread)") + notificationListCmd.Flags().IntP("limit", "L", 30, "Maximum number of notifications to list") + addJSONFlags(notificationListCmd, "Output as JSON") +} + +func runNotificationList(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + all, _ := cmd.Flags().GetBool("all") + limit, _ := cmd.Flags().GetInt("limit") + if limit <= 0 { + limit = 30 + } + + opt := gitea.ListNotificationOptions{ + ListOptions: gitea.ListOptions{PageSize: limit}, + Status: []gitea.NotifyStatus{gitea.NotifyStatusUnread}, + } + if all { + opt.Status = []gitea.NotifyStatus{gitea.NotifyStatusUnread, gitea.NotifyStatusRead, gitea.NotifyStatusPinned} + } + + threads, _, err := client.ListNotifications(opt) + if err != nil { + return fmt.Errorf("failed to list notifications: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, threads) + } + + if len(threads) == 0 { + fmt.Fprintln(ios.Out, "No notifications.") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("ID", "REPO", "TYPE", "STATE", "TITLE") + for _, t := range threads { + repo := "" + if t.Repository != nil { + repo = t.Repository.FullName + } + subjType, subjState, title := "", "", "" + if t.Subject != nil { + subjType = string(t.Subject.Type) + subjState = string(t.Subject.State) + title = t.Subject.Title + } + tp.AddRow(fmt.Sprintf("%d", t.ID), repo, subjType, subjState, title) + } + return tp.Render() +} + +func runNotificationRead(cmd *cobra.Command, args []string) error { + id, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid notification id %q: %w", args[0], err) + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + if _, _, err := client.ReadNotification(id, gitea.NotifyStatusRead); err != nil { + return fmt.Errorf("failed to mark notification %d as read: %w", id, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Marked notification %d as read\n", cs.SuccessIcon(), id) + return nil +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..cde0a06 --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "fmt" + "os/exec" + "runtime" + + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +var openCmd = &cobra.Command{ + Use: "open [issue-or-pr-number]", + Aliases: []string{"o"}, + Short: "Open a repository, issue, or pull request in a browser", + Long: `Open the repository page in a web browser. When an issue or pull request +number is given, that page is opened instead. + +Repository is auto-detected from the current git context, or specified with -R.`, + Example: ` # Open the current repository + fgj open + + # Open a specific repository + fgj open -R owner/repo + + # Open issue or PR #42 (Forgejo routes both via the same number) + fgj open 42 + fgj open '#42' + + # Print the URL instead of launching a browser + fgj open 42 --url`, + Args: cobra.MaximumNArgs(1), + RunE: runOpen, +} + +func init() { + rootCmd.AddCommand(openCmd) + addRepoFlags(openCmd) + openCmd.Flags().Bool("url", false, "Print URL instead of opening a browser") +} + +func runOpen(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + url := fmt.Sprintf("https://%s/%s/%s", client.Hostname(), owner, name) + + if len(args) == 1 { + num, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid issue or PR number %q: %w", args[0], err) + } + issue, _, err := client.GetIssue(owner, name, num) + if err != nil { + return fmt.Errorf("failed to look up #%d: %w", num, err) + } + kind := "issues" + if issue.PullRequest != nil { + kind = "pulls" + } + url = fmt.Sprintf("https://%s/%s/%s/%s/%d", client.Hostname(), owner, name, kind, num) + } + + printOnly, _ := cmd.Flags().GetBool("url") + if printOnly || !ios.IsStdoutTTY() { + fmt.Fprintln(ios.Out, url) + return nil + } + + if err := launchBrowser(url); err != nil { + fmt.Fprintf(ios.ErrOut, "Could not open browser (%v); URL: %s\n", err, url) + return nil + } + fmt.Fprintf(ios.ErrOut, "Opening %s in your browser.\n", url) + return nil +} + +// launchBrowser opens url in the OS default browser. +func launchBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/cmd/org.go b/cmd/org.go new file mode 100644 index 0000000..8e11804 --- /dev/null +++ b/cmd/org.go @@ -0,0 +1,186 @@ +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 orgCmd = &cobra.Command{ + Use: "org", + Aliases: []string{"organization", "organizations"}, + Short: "Manage organizations", + Long: "List, create, and delete organizations on the current host.", +} + +var orgListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List organizations", + Long: "List organizations the authenticated user is a member of.", + Example: ` # List your organizations + fgj org list + + # Output as JSON + fgj org list --json`, + RunE: runOrgList, +} + +var orgCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create an organization", + Long: "Create a new organization. You become the initial owner.", + Example: ` # Create an organization + fgj org create my-org + + # Create with description and visibility + fgj org create my-org --description "Internal tooling" --visibility private`, + Args: cobra.ExactArgs(1), + RunE: runOrgCreate, +} + +var orgDeleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete an organization", + Long: "Delete an organization. This is irreversible and removes all the organization's repositories.", + Args: cobra.ExactArgs(1), + RunE: runOrgDelete, +} + +func init() { + rootCmd.AddCommand(orgCmd) + orgCmd.AddCommand(orgListCmd) + orgCmd.AddCommand(orgCreateCmd) + orgCmd.AddCommand(orgDeleteCmd) + + orgListCmd.Flags().IntP("limit", "L", 50, "Maximum number of organizations to list") + addJSONFlags(orgListCmd, "Output as JSON") + + orgCreateCmd.Flags().String("description", "", "Organization description") + orgCreateCmd.Flags().String("full-name", "", "Full display name") + orgCreateCmd.Flags().String("website", "", "Website URL") + orgCreateCmd.Flags().String("location", "", "Location") + orgCreateCmd.Flags().String("visibility", "public", "Visibility: public, limited, or private") + + orgDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +func runOrgList(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + + limit, _ := cmd.Flags().GetInt("limit") + if limit <= 0 { + limit = 50 + } + + orgs, _, err := client.ListMyOrgs(gitea.ListOrgsOptions{ + ListOptions: gitea.ListOptions{PageSize: limit}, + }) + if err != nil { + return fmt.Errorf("failed to list organizations: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, orgs) + } + + if len(orgs) == 0 { + fmt.Fprintln(ios.Out, "No organizations found.") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "FULL NAME", "VISIBILITY", "DESCRIPTION") + for _, o := range orgs { + tp.AddRow(o.UserName, o.FullName, string(o.Visibility), o.Description) + } + return tp.Render() +} + +func runOrgCreate(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + + name := args[0] + desc, _ := cmd.Flags().GetString("description") + fullName, _ := cmd.Flags().GetString("full-name") + website, _ := cmd.Flags().GetString("website") + location, _ := cmd.Flags().GetString("location") + visStr, _ := cmd.Flags().GetString("visibility") + + var vis gitea.VisibleType + switch visStr { + case "public", "": + vis = gitea.VisibleTypePublic + case "limited": + vis = gitea.VisibleTypeLimited + case "private": + vis = gitea.VisibleTypePrivate + default: + return fmt.Errorf("invalid visibility %q (must be public, limited, or private)", visStr) + } + + org, _, err := client.CreateOrg(gitea.CreateOrgOption{ + Name: name, + FullName: fullName, + Description: desc, + Website: website, + Location: location, + Visibility: vis, + }) + if err != nil { + return fmt.Errorf("failed to create organization %q: %w", name, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Created organization %q\n", cs.SuccessIcon(), org.UserName) + return nil +} + +func runOrgDelete(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + + name := args[0] + skipConfirm, _ := cmd.Flags().GetBool("yes") + + if !skipConfirm && ios.IsStdinTTY() { + answer, err := promptLine(fmt.Sprintf("Delete organization %q? This is irreversible and deletes all repositories. [y/N]: ", name)) + if err != nil { + return err + } + if answer != "y" && answer != "Y" && answer != "yes" { + fmt.Fprintln(ios.ErrOut, "Cancelled.") + return nil + } + } + + if _, err := client.DeleteOrg(name); err != nil { + return fmt.Errorf("failed to delete organization %q: %w", name, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Deleted organization %q\n", cs.SuccessIcon(), name) + return nil +} + +// loadClient constructs an api.Client from config without requiring a repo context. +// Use this for commands that operate on the host itself (orgs, notifications, user). +func loadClient() (*api.Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + return api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) +} diff --git a/cmd/whoami.go b/cmd/whoami.go new file mode 100644 index 0000000..9955eb8 --- /dev/null +++ b/cmd/whoami.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var whoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "Show the authenticated user on the current host", + Long: "Display login, full name, and email for the authenticated user on the active host.", + Example: ` # Show who you are on the active host + fgj whoami + + # On a specific host + fgj whoami --hostname forgejo.example.com + + # As JSON + fgj whoami --json`, + RunE: runWhoami, +} + +func init() { + rootCmd.AddCommand(whoamiCmd) + addJSONFlags(whoamiCmd, "Output as JSON") +} + +func runWhoami(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + + user, _, err := client.GetMyUserInfo() + if err != nil { + return fmt.Errorf("failed to fetch current user: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, user) + } + + fmt.Fprintf(ios.Out, "%s\n", user.UserName) + if user.FullName != "" && user.FullName != user.UserName { + fmt.Fprintf(ios.Out, " name: %s\n", user.FullName) + } + if user.Email != "" { + fmt.Fprintf(ios.Out, " email: %s\n", user.Email) + } + fmt.Fprintf(ios.Out, " host: %s\n", client.Hostname()) + return nil +}