diff --git a/cmd/repo.go b/cmd/repo.go index 286efc7..b072317 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "text/tabwriter" "code.gitea.io/sdk/gitea" @@ -50,12 +51,35 @@ var repoForkCmd = &cobra.Command{ RunE: runRepoFork, } +var repoCreateCmd = &cobra.Command{ + Use: "create ", + 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(repoViewCmd) - repoCmd.AddCommand(repoListCmd) 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") } @@ -237,3 +261,125 @@ func runRepoFork(cmd *cobra.Command, args []string) error { 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 +} diff --git a/cmd/repo_create_test.go b/cmd/repo_create_test.go new file mode 100644 index 0000000..6405553 --- /dev/null +++ b/cmd/repo_create_test.go @@ -0,0 +1,53 @@ +package cmd + +import "testing" + +func TestParseCreateName(t *testing.T) { + t.Run("simple repo name", func(t *testing.T) { + org, repo, isOrg, err := parseCreateName("myrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if org != "" || repo != "myrepo" || isOrg { + t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", org, repo, isOrg, "", "myrepo", false) + } + }) + + t.Run("org/repo name", func(t *testing.T) { + org, repo, isOrg, err := parseCreateName("myorg/myrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if org != "myorg" || repo != "myrepo" || !isOrg { + t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", org, repo, isOrg, "myorg", "myrepo", true) + } + }) + + t.Run("empty string", func(t *testing.T) { + _, _, _, err := parseCreateName("") + if err == nil { + t.Error("expected error for empty string, got nil") + } + }) + + t.Run("slash only", func(t *testing.T) { + _, _, _, err := parseCreateName("/") + if err == nil { + t.Error("expected error for '/', got nil") + } + }) + + t.Run("empty repo after slash", func(t *testing.T) { + _, _, _, err := parseCreateName("org/") + if err == nil { + t.Error("expected error for 'org/', got nil") + } + }) + + t.Run("empty org before slash", func(t *testing.T) { + _, _, _, err := parseCreateName("/repo") + if err == nil { + t.Error("expected error for '/repo', got nil") + } + }) +} diff --git a/tests/functional/fixtures.go b/tests/functional/fixtures.go index e510a92..5423e5b 100644 --- a/tests/functional/fixtures.go +++ b/tests/functional/fixtures.go @@ -219,6 +219,14 @@ func (env *TestEnv) GetLabelIDs(labelNames []string) []int64 { return ids } +// CleanupRepo deletes a repository created during testing. +func (env *TestEnv) CleanupRepo(owner, repoName string) { + _, err := env.Client.DeleteRepo(owner, repoName) + if err != nil { + env.T.Logf("warning: failed to delete repo %s/%s: %v", owner, repoName, err) + } +} + // GetBinaryPath returns the path to the built fgj binary func (env *TestEnv) GetBinaryPath() string { binaryPath := os.Getenv("FGJ_BINARY_PATH") diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 1764ba6..3803e56 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -940,3 +940,42 @@ func TestCLIReleaseList(t *testing.T) { t.Logf("Successfully listed releases via CLI") } + +// TestCLIRepoCreate verifies `fgj repo create` creates a repository +func TestCLIRepoCreate(t *testing.T) { + env := NewTestEnv(t) + + repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano()) + defer env.CleanupRepo(env.Owner, repoName) + + result := env.RunCLI( + "--hostname", env.Hostname, + "repo", "create", repoName, + "--public", + "-d", "Created by fgj functional test", + ) + + if result.ExitCode != 0 { + t.Fatalf("repo create failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !bytes.Contains([]byte(result.Stdout), []byte(repoName)) { + t.Fatalf("expected output to contain repo name %q, got: %s", repoName, result.Stdout) + } + + repo, _, err := env.Client.GetRepo(env.Owner, repoName) + if err != nil { + t.Fatalf("repo was not created or not accessible: %v", err) + } + if repo.Name != repoName { + t.Fatalf("expected repo name %q, got %q", repoName, repo.Name) + } + if repo.Private { + t.Fatalf("expected public repo, got private") + } + if repo.Description != "Created by fgj functional test" { + t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description) + } + + t.Logf("Successfully created repository %s via CLI", repo.FullName) +}