Merge pull request 'feat: implement repo create command' (#35) from feat/repo-create into main
Reviewed-on: https://codeberg.org/romaintb/fgj/pulls/35
This commit is contained in:
commit
fbdb2320cc
4 changed files with 248 additions and 2 deletions
150
cmd/repo.go
150
cmd/repo.go
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
@ -50,12 +51,35 @@ var repoForkCmd = &cobra.Command{
|
||||||
RunE: runRepoFork,
|
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() {
|
func init() {
|
||||||
rootCmd.AddCommand(repoCmd)
|
rootCmd.AddCommand(repoCmd)
|
||||||
repoCmd.AddCommand(repoViewCmd)
|
|
||||||
repoCmd.AddCommand(repoListCmd)
|
|
||||||
repoCmd.AddCommand(repoCloneCmd)
|
repoCmd.AddCommand(repoCloneCmd)
|
||||||
|
repoCmd.AddCommand(repoCreateCmd)
|
||||||
repoCmd.AddCommand(repoForkCmd)
|
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")
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
53
cmd/repo_create_test.go
Normal file
53
cmd/repo_create_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -219,6 +219,14 @@ func (env *TestEnv) GetLabelIDs(labelNames []string) []int64 {
|
||||||
return ids
|
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
|
// GetBinaryPath returns the path to the built fgj binary
|
||||||
func (env *TestEnv) GetBinaryPath() string {
|
func (env *TestEnv) GetBinaryPath() string {
|
||||||
binaryPath := os.Getenv("FGJ_BINARY_PATH")
|
binaryPath := os.Getenv("FGJ_BINARY_PATH")
|
||||||
|
|
|
||||||
|
|
@ -940,3 +940,42 @@ func TestCLIReleaseList(t *testing.T) {
|
||||||
|
|
||||||
t.Logf("Successfully listed releases via CLI")
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue