fj/cmd/repo.go
sid 43e43e7024 feat: v0.3.0a — add api command, pr diff/comment/review, structured errors
New commands:
- fgj api: raw REST API passthrough with field inference and path interpolation
- fgj pr diff: view PR diffs with color, --name-only, --stat
- fgj pr comment: add comments to pull requests
- fgj pr review: approve, request changes, or comment on PRs

Agentic enhancements:
- --json-errors flag for structured JSON error output on stderr
- APIError type wrapping HTTP status codes for machine consumption
- Error codes: auth_required, not_found, api_error, invalid_input, etc.

Docs updated for forgejo.zerova.net/sid/fgj-sid fork.
2026-03-21 21:50:24 -06:00

385 lines
10 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage repositories",
Long: "View and manage repositories.",
}
var repoViewCmd = &cobra.Command{
Use: "view [owner/name]",
Short: "View repository details",
Long: "Display detailed information about a repository.",
Args: cobra.MaximumNArgs(1),
RunE: runRepoView,
}
var repoListCmd = &cobra.Command{
Use: "list",
Short: "List your repositories",
Long: "List repositories owned by the authenticated user.",
RunE: runRepoList,
}
var repoCloneCmd = &cobra.Command{
Use: "clone <owner/name> [destination]",
Short: "Clone a repository",
Long: "Clone a repository locally. If destination is not specified, the repository name is used.",
Args: cobra.RangeArgs(1, 2),
RunE: runRepoClone,
}
var repoForkCmd = &cobra.Command{
Use: "fork <owner/name>",
Short: "Fork a repository",
Long: "Create a fork of a repository.",
Args: cobra.ExactArgs(1),
RunE: runRepoFork,
}
var repoCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a new repository",
Long: `Create a new repository on the Forgejo instance.
Name can be "reponame" (creates under your account) or "org/reponame"
(creates under an organization). Repositories are public by default; use --private to create a private one.`,
Args: cobra.ExactArgs(1),
RunE: runRepoCreate,
}
func init() {
rootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoCloneCmd)
repoCmd.AddCommand(repoCreateCmd)
repoCmd.AddCommand(repoForkCmd)
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoViewCmd)
repoCreateCmd.Flags().StringP("description", "d", "", "Description of the repository")
repoCreateCmd.Flags().Bool("private", false, "Make the new repository private")
repoCreateCmd.Flags().Bool("public", false, "Make the new repository public")
repoCreateCmd.Flags().Bool("add-readme", false, "Add a README file to the new repository")
repoCreateCmd.Flags().StringP("gitignore", "g", "", "Gitignore template (e.g. Go, Python)")
repoCreateCmd.Flags().StringP("license", "l", "", "License template (e.g. MIT, Apache-2.0)")
repoCreateCmd.Flags().String("homepage", "", "Repository home page URL")
repoCreateCmd.Flags().BoolP("clone", "c", false, "Clone the new repository to the current directory")
repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)")
repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private")
repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh")
}
func runRepoView(cmd *cobra.Command, args []string) error {
var repo string
if len(args) > 0 {
repo = args[0]
}
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())
if err != nil {
return err
}
repository, _, err := client.GetRepo(owner, name)
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name)
fmt.Printf("Description: %s\n", repository.Description)
fmt.Printf("URL: %s\n", repository.HTMLURL)
fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL)
fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL)
fmt.Printf("Default Branch: %s\n", repository.DefaultBranch)
fmt.Printf("Stars: %d\n", repository.Stars)
fmt.Printf("Forks: %d\n", repository.Forks)
fmt.Printf("Open Issues: %d\n", repository.OpenIssues)
fmt.Printf("Private: %v\n", repository.Private)
fmt.Printf("Created: %s\n", repository.Created.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05"))
return nil
}
func runRepoList(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
user, _, err := client.GetMyUserInfo()
if err != nil {
return fmt.Errorf("failed to get user info: %w", err)
}
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
if err != nil {
return fmt.Errorf("failed to list repositories: %w", err)
}
if len(repos) == 0 {
fmt.Println("No repositories found")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n")
for _, repo := range repos {
visibility := "public"
if repo.Private {
visibility = "private"
}
desc := repo.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
}
_ = w.Flush()
return nil
}
func runRepoClone(cmd *cobra.Command, args []string) error {
repo := args[0]
protocol, _ := cmd.Flags().GetString("protocol")
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())
if err != nil {
return err
}
repository, _, err := client.GetRepo(owner, name)
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
var cloneURL string
if protocol == "ssh" {
cloneURL = repository.SSHURL
} else {
cloneURL = repository.CloneURL
}
// Determine destination path
var destination string
if len(args) > 1 {
destination = args[1]
} else {
destination = name
}
fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination)
// Create parent directory if it doesn't exist
if dir := filepath.Dir(destination); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
}
// Execute git clone
gitCmd := exec.Command("git", "clone", cloneURL, destination)
gitCmd.Stdout = os.Stdout
gitCmd.Stderr = os.Stderr
gitCmd.Stdin = os.Stdin
if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
fmt.Printf("Repository cloned successfully to %s\n", destination)
return nil
}
func runRepoFork(cmd *cobra.Command, args []string) error {
repo := args[0]
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())
if err != nil {
return err
}
fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{})
if err != nil {
return fmt.Errorf("failed to fork repository: %w", err)
}
fmt.Printf("Repository forked successfully\n")
fmt.Printf("View at: %s\n", fork.HTMLURL)
fmt.Printf("Clone URL: %s\n", fork.CloneURL)
return nil
}
func runRepoCreate(cmd *cobra.Command, args []string) error {
inputName := args[0]
org, repoName, isOrg, err := parseCreateName(inputName)
if err != nil {
return err
}
private, _ := cmd.Flags().GetBool("private")
description, _ := cmd.Flags().GetString("description")
addReadme, _ := cmd.Flags().GetBool("add-readme")
gitignore, _ := cmd.Flags().GetString("gitignore")
license, _ := cmd.Flags().GetString("license")
homepage, _ := cmd.Flags().GetString("homepage")
doClone, _ := cmd.Flags().GetBool("clone")
team, _ := cmd.Flags().GetString("team")
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
opt := gitea.CreateRepoOption{
Name: repoName,
Description: description,
Private: private,
AutoInit: addReadme,
Gitignores: gitignore,
License: license,
}
var repo *gitea.Repository
if isOrg {
repo, _, err = client.CreateOrgRepo(org, opt)
} else {
repo, _, err = client.CreateRepo(opt)
}
if err != nil {
return fmt.Errorf("failed to create repository: %w", err)
}
if homepage != "" {
ownerName := org
if !isOrg {
// For personal repos, get the owner from the created repo or fall back to API
if repo.Owner != nil {
ownerName = repo.Owner.UserName
} else {
user, _, userErr := client.GetMyUserInfo()
if userErr != nil {
fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
homepage = "" // skip EditRepo
} else {
ownerName = user.UserName
}
}
}
if homepage != "" {
_, _, err = client.EditRepo(ownerName, repo.Name, gitea.EditRepoOption{
Website: &homepage,
})
if err != nil {
fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err)
}
}
}
if team != "" {
if !isOrg {
fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories")
} else {
_, err = client.AddRepoTeam(org, repo.Name, team)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err)
}
}
}
fmt.Printf("Repository created: %s\n", repo.HTMLURL)
if doClone {
cloneURL := repo.CloneURL
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil {
if hostCfg.GitProtocol == "ssh" {
cloneURL = repo.SSHURL
}
}
fmt.Printf("Cloning into %s...\n", repo.Name)
gitCmd := exec.Command("git", "clone", cloneURL, repo.Name)
gitCmd.Stdout = os.Stdout
gitCmd.Stderr = os.Stderr
gitCmd.Stdin = os.Stdin
if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
}
return nil
}
// parseCreateName parses the name argument for repo creation.
// Returns org, repoName, and whether it targets an org.
// Input: "reponame" → ("", "reponame", false)
// Input: "org/reponame" → ("org", "reponame", true)
func parseCreateName(name string) (org, repoName string, isOrg bool, err error) {
parts := strings.SplitN(name, "/", 2)
if len(parts) == 2 {
if parts[0] == "" || parts[1] == "" {
return "", "", false, fmt.Errorf("invalid repository name %q: org and repo name must not be empty", name)
}
return parts[0], parts[1], true, nil
}
if name == "" {
return "", "", false, fmt.Errorf("repository name must not be empty")
}
return "", name, false, nil
}