feat(repo): validate create templates, publish local projects, verify edits

repo create --license NOPE returned Gitea's HTTP 500 internals. License,
.gitignore and label templates are now checked against the server's
lists, case-insensitively, and complete with Tab. --source DIR adds the
new repository as a remote of a local Git repository and --push pushes
its current branch; --public is the explicit opposite of --private, and
-d/-g/-l and the new alias follow gh. --trust-model now offers the names
Gitea accepts. A prompted name is confirmed with the visibility, owner
and host, and creation reports "✓ Created repository ..." and the URL.

repo edit --default-branch nope exited 0 without changing anything; the
branch must now exist. Edit gains --visibility, --enable-issues/-wiki/
-pull-requests/-projects, --allow-merge-commit/-squash/-rebase,
--delete-branch-on-merge, --default-merge-style and --add-topic/
--remove-topic, and reports which settings it changed.

repo migrate names the repository after the source URL, validates
--service before prompting, and reports the import with the URL.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:51:49 +02:00
1 parent 368de47c40
commit 682c9f66a2
9 files changed
+619 -185

No files matched your search

-3
View File
@@ -505,9 +505,6 @@ const (
release list release list
release publish release publish
release view release view
repo create
repo edit
repo migrate
search issue search issue
search pr search pr
status status
+131 -46
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
@@ -16,12 +17,9 @@ import (
"gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
func repositoryRecord(item services.Repository) terminal.Record {
return terminal.Record{Heading: []terminal.Cell{{Text: item.FullName, Bold: true}}, Lines: []string{item.URL}}
}
func cloneURLForProtocol(cloneURL, sshURL, protocol string) (remote string, ssh bool, err error) { func cloneURLForProtocol(cloneURL, sshURL, protocol string) (remote string, ssh bool, err error) {
remote = cloneURL remote = cloneURL
if protocol == "ssh" { if protocol == "ssh" {
@@ -73,72 +71,159 @@ func mirrorSourceURL(item services.Repository) string {
} }
func (a *application) addRepositoryMutations(group *cobra.Command) { func (a *application) addRepositoryMutations(group *cobra.Command) {
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand(), a.repoForkCommand(), a.repoCloneCommand()) group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand(), a.repoForkCommand(), a.repoCloneCommand(), a.repoEditCommand())
var description, repoName, website, defaultBranch string group.AddCommand(a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand())
var private, template, repoArchived, actionsEnabled bool }
edit := &cobra.Command{Use: "edit [repository]", Short: "Edit repository settings", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
// repositorySwitches are the on/off settings of repo edit: each flag sets one field, and =false
// turns the setting off.
var repositorySwitches = []struct {
flag, usage string
field func(*services.RepositoryEdit) **bool
}{
{"private", "Make the repository private (=false makes it public)", func(e *services.RepositoryEdit) **bool { return &e.Private }},
{"as-template", "Make the repository a template", func(e *services.RepositoryEdit) **bool { return &e.Template }},
{"enable-issues", "Enable issues", func(e *services.RepositoryEdit) **bool { return &e.Issues }},
{"enable-wiki", "Enable the wiki", func(e *services.RepositoryEdit) **bool { return &e.Wiki }},
{"enable-pull-requests", "Enable pull requests", func(e *services.RepositoryEdit) **bool { return &e.PullRequests }},
{"enable-projects", "Enable projects", func(e *services.RepositoryEdit) **bool { return &e.Projects }},
{"actions", "Enable Actions", func(e *services.RepositoryEdit) **bool { return &e.ActionsEnabled }},
{"allow-merge-commit", "Allow merging pull requests with a merge commit", func(e *services.RepositoryEdit) **bool { return &e.MergeCommits }},
{"allow-squash", "Allow squash merging pull requests", func(e *services.RepositoryEdit) **bool { return &e.Squash }},
{"allow-rebase", "Allow rebase merging pull requests", func(e *services.RepositoryEdit) **bool { return &e.Rebase }},
{"delete-branch-on-merge", "Delete head branches after merging pull requests", func(e *services.RepositoryEdit) **bool { return &e.DeleteBranch }},
{"archived", "Archive the repository (=false unarchives it)", func(e *services.RepositoryEdit) **bool { return &e.Archived }},
}
func (a *application) repoEditCommand() *cobra.Command {
var name, website, defaultBranch, description, visibility, mergeStyle string
var addTopics, removeTopics []string
switches := make([]bool, len(repositorySwitches))
command := &cobra.Command{
Use: "edit [<repository>]",
Short: "Edit repository settings",
Long: `Edit the settings of a repository. Only the flags you pass change; on/off settings such as
--enable-wiki take =false to turn them off.`,
Example: `# Change the description and the website
$ tea repo edit --description "Command line tool" --website https://example.com
# Make a repository private and disable its wiki
$ tea repo edit alice/project --visibility private --enable-wiki=false
# Squash pull requests by default and tag the repository
$ tea repo edit --default-merge-style squash --add-topic cli --add-topic go`,
Annotations: map[string]string{"tea.arguments": repositoryArguments},
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil { if err := a.repositoryArgument(args); err != nil {
return err return err
} }
changed := cmd.Flags().Changed
options := services.RepositoryEdit{} options := services.RepositoryEdit{}
if cmd.Flags().Changed("name") { for i, toggle := range repositorySwitches {
if strings.TrimSpace(repoName) == "" { if changed(toggle.flag) {
return clierr.Wrap(clierr.Usage, errors.New("repository name cannot be empty")) *toggle.field(&options) = &switches[i]
} }
options.Name = &repoName
} }
if cmd.Flags().Changed("website") { for _, field := range []struct {
flag string
value *string
into **string
}{{"name", &name, &options.Name}, {"default-branch", &defaultBranch, &options.DefaultBranch}, {"default-merge-style", &mergeStyle, &options.MergeStyle}} {
if changed(field.flag) {
if strings.TrimSpace(*field.value) == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", field.flag))
}
*field.into = field.value
}
}
if changed("website") {
options.Website = &website options.Website = &website
} }
if cmd.Flags().Changed("default-branch") { if changed("description") {
if strings.TrimSpace(defaultBranch) == "" {
return clierr.Wrap(clierr.Usage, errors.New("default branch cannot be empty"))
}
options.DefaultBranch = &defaultBranch
}
if cmd.Flags().Changed("archived") {
options.Archived = &repoArchived
}
if cmd.Flags().Changed("description") {
options.Description = &description options.Description = &description
} }
if cmd.Flags().Changed("private") { if changed("visibility") {
if changed("private") {
return clierr.Wrap(clierr.Usage, errors.New("--visibility and --private cannot be combined"))
}
private := visibility == "private"
options.Private = &private options.Private = &private
} }
if cmd.Flags().Changed("as-template") { if err := requireNames("topic", "", addTopics, removeTopics); err != nil {
options.Template = &template return err
} }
if cmd.Flags().Changed("actions") { if options == (services.RepositoryEdit{}) && len(addTopics)+len(removeTopics) == 0 {
options.ActionsEnabled = &actionsEnabled return clierr.Wrap(clierr.Usage, errors.New("supply a setting to change; see tea repo edit --help"))
}
if options == (services.RepositoryEdit{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea repo edit --help"))
} }
t, err := a.resolve(cmd.Context(), true) t, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
if repoArchived { service := services.Repositories{Client: t.client}
if err := a.confirm(cmd, "Archive repository "+t.Repo); err != nil { if options.DefaultBranch != nil {
if _, err := (services.Branches{Client: t.client}).View(cmd.Context(), t.Repo, defaultBranch); err != nil {
return services.Classify(err, "branch", strconv.Quote(defaultBranch), t.Repo)
}
}
if options.Archived != nil && *options.Archived {
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Archive " + t.Repo + "? It becomes read-only.", NonTTY: "archive " + t.Repo}); err != nil {
return err return err
} }
} }
item, err := (services.Repositories{Client: t.client}).Edit(cmd.Context(), t.Repo, options) repo := t.Repo
if options != (services.RepositoryEdit{}) {
item, err := service.Edit(cmd.Context(), t.Repo, options)
if err != nil { if err != nil {
return err return err
} }
return writeRecord(a, cmd, item, repositoryRecord) repo = item.FullName
}} }
edit.Flags().StringVar(&repoName, "name", "", "Rename the repository") for _, topic := range addTopics {
edit.Flags().StringVar(&website, "website", "", "Repository website; empty clears") if err := service.AddTopic(cmd.Context(), repo, topic); err != nil {
edit.Flags().StringVar(&defaultBranch, "default-branch", "", "Default branch name") return fmt.Errorf("add topic %q: %w", topic, err)
edit.Flags().BoolVar(&repoArchived, "archived", false, "Archive repository; =false restores it") }
edit.Flags().StringVar(&description, "description", "", "Repository description") }
edit.Flags().BoolVar(&private, "private", false, "Make repository private (use --private=false to make public)") for _, topic := range removeTopics {
edit.Flags().BoolVar(&template, "as-template", false, "Make repository a template (use =false to disable)") if err := service.RemoveTopic(cmd.Context(), repo, topic); err != nil {
edit.Flags().BoolVar(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)") return fmt.Errorf("remove topic %q: %w", topic, err)
group.AddCommand(edit, a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand()) }
}
var edited []string
local := cmd.LocalFlags()
cmd.Flags().Visit(func(flag *pflag.Flag) {
if local.Lookup(flag.Name) != nil {
edited = append(edited, flag.Name)
}
})
a.done(cmd, "Edited repository %s (%s)", repo, strings.Join(edited, ", "))
if a.options.json == "" {
return nil
}
item, err := service.View(cmd.Context(), repo)
if err != nil {
return err
}
return a.writeJSON(cmd, item)
},
}
flags := command.Flags()
flags.StringVar(&name, "name", "", "Rename the repository")
_ = flags.MarkHidden("name")
flags.StringVarP(&description, "description", "d", "", "Repository description")
flags.StringVar(&website, "website", "", "Repository website (empty clears it)")
flags.StringVar(&defaultBranch, "default-branch", "", "Default branch; it must exist")
enumFlag(command, &visibility, "visibility", "", "", "Repository visibility", "public", "private")
enumFlag(command, &mergeStyle, "default-merge-style", "", "", "Default merge style for pull requests", "merge", "rebase", "rebase-merge", "squash", "fast-forward-only")
flags.StringSliceVar(&addTopics, "add-topic", nil, "Add topics (repeatable or comma-separated)")
flags.StringSliceVar(&removeTopics, "remove-topic", nil, "Remove topics (repeatable or comma-separated)")
for i, toggle := range repositorySwitches {
flags.BoolVar(&switches[i], toggle.flag, false, toggle.usage)
}
_ = flags.MarkHidden("archived")
_ = command.RegisterFlagCompletionFunc("default-branch", a.completeBranches())
command.ValidArgsFunction = a.completeRepos()
return command
} }
func (a *application) repoCloneCommand() *cobra.Command { func (a *application) repoCloneCommand() *cobra.Command {
+207 -67
View File
@@ -5,12 +5,16 @@ package app
import ( import (
"cmp" "cmp"
"context"
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"slices"
"strings" "strings"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -24,9 +28,29 @@ func validateRepositoryName(name string) error {
func (a *application) repoCreateCommand() *cobra.Command { func (a *application) repoCreateCommand() *cobra.Command {
var opts services.RepositoryOptions var opts services.RepositoryOptions
var templateOpts services.RepositoryTemplateOptions var templateOpts services.RepositoryTemplateOptions
var org, source, team string var org, template, team, localSource, remote string
var clone bool var clone, public, push bool
command := &cobra.Command{Use: "create [name]", Short: "Create a repository, optionally from a template", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { command := &cobra.Command{
Use: "create [<name>]",
Aliases: []string{"new"},
Short: "Create a repository",
Long: `Create a repository for your account, or for an organization as ORG/NAME or with --org.
A new repository is empty unless --auto-init adds a first commit with a README and, optionally, a
.gitignore and a license from the server's templates. --template copies another repository instead.
--source publishes an existing local Git repository: it adds the new repository as a remote of
it (origin unless --remote names another), and --push pushes the current branch there.`,
Example: `# Create a private repository with a README and an MIT license
$ tea repo create project --private --auto-init --license MIT
# Create a repository in an organization from a template
$ tea repo create acme/service --template acme/service-template
# Publish the local repository in the current directory
$ tea repo create project --source . --push`,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 { if len(args) > 0 {
if cmd.Flags().Changed("name") { if cmd.Flags().Changed("name") {
return clierr.Wrap(clierr.Usage, errors.New("supply name as an argument or --name, not both")) return clierr.Wrap(clierr.Usage, errors.New("supply name as an argument or --name, not both"))
@@ -42,53 +66,32 @@ func (a *application) repoCreateCommand() *cobra.Command {
} }
org, opts.Name = owner, name org, opts.Name = owner, name
} }
if cmd.Flags().Changed("team") && (org == "" || strings.TrimSpace(team) == "") { if err := validateCreateFlags(cmd, opts, org, team, template, localSource, clone); err != nil {
return clierr.Wrap(clierr.Usage, errors.New("--team requires --org and a team name")) return err
} }
if cmd.Flags().Changed("template") && source == "" { if public {
return clierr.Wrap(clierr.Usage, errors.New("--template requires owner/repo or a repository URL")) if cmd.Flags().Changed("private") {
return clierr.Wrap(clierr.Usage, errors.New("--public and --private cannot be combined"))
} }
prompted, err := a.require(cmd, &opts.Name, "name") opts.Private = false
}
if localSource != "" {
if !filepath.IsAbs(localSource) {
localSource = filepath.Join(a.options.cwd, localSource)
}
if err := a.checkPublishSource(cmd.Context(), localSource, remote); err != nil {
return err
}
}
prompted, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", func(name string) error { return validateRepositoryName(name) })
if err != nil { if err != nil {
return err return err
} }
if err := validateRepositoryName(opts.Name); err != nil { if err := validateRepositoryName(opts.Name); err != nil {
return err return err
} }
if len(opts.Description) > 2048 {
return clierr.Wrap(clierr.Usage, errors.New("description cannot exceed 2048 bytes"))
}
if opts.DefaultBranch != "" {
if err := validateBranch(opts.DefaultBranch); err != nil {
return err
}
}
switch opts.TrustModel {
case "", "committer", "collaborator", "collaborator+committer":
default:
return clierr.Wrap(clierr.Usage, errors.New("--trust-model must be committer, collaborator, or collaborator+committer"))
}
if opts.ObjectFormat != "" && opts.ObjectFormat != "sha1" && opts.ObjectFormat != "sha256" {
return clierr.Wrap(clierr.Usage, errors.New("--object-format must be sha1 or sha256"))
}
if source == "" && (opts.Gitignores != "" || opts.License != "" || opts.Readme != "" || opts.DefaultBranch != "") && !opts.AutoInit {
return clierr.Wrap(clierr.Usage, errors.New("initial content and default branch options require --auto-init"))
}
selected := *a selected := *a
if source != "" { selected.options.repo = template
for _, flag := range []string{"auto-init", "label-template", "gitignores", "license", "readme", "as-template", "trust-model", "object-format"} {
if cmd.Flags().Changed(flag) {
return clierr.Wrap(clierr.Usage, errors.New("repository initialization options cannot be combined with --template"))
}
}
selected.options.repo = source
} else {
for _, flag := range []string{"copy-content", "copy-git-hooks", "copy-avatar", "copy-labels", "copy-topics", "copy-webhooks"} {
if cmd.Flags().Changed(flag) {
return clierr.Wrap(clierr.Usage, errors.New("copy options require --template owner/repo"))
}
}
}
target, err := selected.resolve(cmd.Context(), false) target, err := selected.resolve(cmd.Context(), false)
if err != nil { if err != nil {
return err return err
@@ -99,8 +102,17 @@ func (a *application) repoCreateCommand() *cobra.Command {
} }
org = "" org = ""
} }
service := services.Repositories{Client: target.client}
if err := validateTemplates(cmd.Context(), service, &opts); err != nil {
return err
}
if prompted { if prompted {
if err := a.confirm(cmd, "Create repository "+opts.Name+" on "+target.Host); err != nil { visibility := "public"
if opts.Private {
visibility = "private"
}
owner := cmp.Or(org, target.User)
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("Create %s repository %s/%s on %s?", visibility, owner, opts.Name, target.Host), DefaultYes: true}); err != nil {
return err return err
} }
} }
@@ -116,9 +128,8 @@ func (a *application) repoCreateCommand() *cobra.Command {
} }
teamID = match.ID teamID = match.ID
} }
service := services.Repositories{Client: target.client}
var item services.Repository var item services.Repository
if source != "" { if template != "" {
templateOpts.Name = opts.Name templateOpts.Name = opts.Name
templateOpts.Owner = org templateOpts.Owner = org
templateOpts.Description = opts.Description templateOpts.Description = opts.Description
@@ -131,40 +142,169 @@ func (a *application) repoCreateCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
a.done(cmd, "Created repository %s", item.FullName)
if teamID != 0 { if teamID != 0 {
if err := (services.Teams{Client: target.client}).SetRepository(cmd.Context(), teamID, item.FullName, true); err != nil { if err := (services.Teams{Client: target.client}).SetRepository(cmd.Context(), teamID, item.FullName, true); err != nil {
return fmt.Errorf("created %s, but could not grant team %q access: %w", item.FullName, team, err) return fmt.Errorf("created %s, but could not grant team %q access: %w", item.FullName, team, err)
} }
} a.done(cmd, "Granted team %s access to %s", team, item.FullName)
if err := writeRecord(a, cmd, item, repositoryRecord); err != nil || !clone {
return err
} }
target.Repo = item.FullName target.Repo = item.FullName
switch {
case clone:
_, err = a.cloneRepository(cmd, target, item, cloneOptions{upstream: "upstream"}) _, err = a.cloneRepository(cmd, target, item, cloneOptions{upstream: "upstream"})
case localSource != "":
err = a.publishRepository(cmd, target, item, localSource, remote, push)
}
if err != nil {
return err return err
}} }
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return a.printURL(cmd, item.URL)
},
}
flags := command.Flags() flags := command.Flags()
flags.StringVar(&opts.Name, "name", "", "Repository name") flags.StringVar(&opts.Name, "name", "", "Repository name")
flags.StringVar(&org, "org", "", "Create under this organization") _ = flags.MarkHidden("name")
flags.StringVar(&team, "team", "", "Grant this organization team access (requires --org)") flags.StringVar(&org, "org", "", "Create the repository in this organization")
flags.StringVar(&team, "team", "", "Grant this organization team access")
flags.BoolVar(&clone, "clone", false, "Clone the new repository into the current directory") flags.BoolVar(&clone, "clone", false, "Clone the new repository into the current directory")
flags.StringVar(&opts.Description, "description", "", "Repository description") flags.StringVarP(&opts.Description, "description", "d", "", "Repository description")
flags.BoolVar(&opts.Private, "private", false, "Create a private repository") flags.BoolVar(&opts.Private, "private", false, "Make the repository private")
flags.BoolVar(&opts.AutoInit, "auto-init", false, "Initialize repository with a README") flags.BoolVar(&public, "public", false, "Make the repository public (the default)")
flags.StringVar(&opts.DefaultBranch, "default-branch", "", "Initial default branch") flags.BoolVar(&opts.AutoInit, "auto-init", false, "Add a first commit with a README")
flags.StringVar(&opts.Labels, "label-template", "", "Named Gitea label set") flags.StringVar(&opts.DefaultBranch, "default-branch", "", "Name of the default branch")
flags.StringVar(&opts.Gitignores, "gitignores", "", "Comma-separated gitignore templates (requires --auto-init)") flags.StringVar(&opts.Labels, "label-template", "", "Add the labels of this label set")
flags.StringVar(&opts.License, "license", "", "License template (requires --auto-init)") flags.StringVarP(&opts.Gitignores, "gitignores", "g", "", "Comma-separated .gitignore templates (requires --auto-init)")
flags.StringVarP(&opts.License, "license", "l", "", "License template (requires --auto-init)")
flags.StringVar(&opts.Readme, "readme", "", "README template (requires --auto-init)") flags.StringVar(&opts.Readme, "readme", "", "README template (requires --auto-init)")
flags.BoolVar(&opts.Template, "as-template", false, "Make the new repository a reusable template") flags.BoolVar(&opts.Template, "as-template", false, "Make the new repository a template")
flags.StringVar(&opts.TrustModel, "trust-model", "", "Signature trust model") enumFlag(command, &opts.TrustModel, "trust-model", "", "", "Signature trust model", "default", "collaborator", "committer", "collaboratorcommitter")
flags.StringVar(&opts.ObjectFormat, "object-format", "", "Git object format: sha1 or sha256") enumFlag(command, &opts.ObjectFormat, "object-format", "", "", "Git object format", "sha1", "sha256")
flags.StringVar(&source, "template", "", "Source template owner/repo or repository URL") flags.StringVar(&template, "template", "", "Copy this template repository (OWNER/REPO or URL)")
flags.BoolVar(&templateOpts.Content, "copy-content", true, "Copy template Git content") flags.BoolVar(&templateOpts.Content, "copy-content", true, "Copy the template's Git content")
flags.BoolVar(&templateOpts.GitHooks, "copy-git-hooks", true, "Copy template Git hooks") flags.BoolVar(&templateOpts.GitHooks, "copy-git-hooks", true, "Copy the template's Git hooks")
flags.BoolVar(&templateOpts.Avatar, "copy-avatar", true, "Copy template avatar") flags.BoolVar(&templateOpts.Avatar, "copy-avatar", true, "Copy the template's avatar")
flags.BoolVar(&templateOpts.Labels, "copy-labels", true, "Copy template labels") flags.BoolVar(&templateOpts.Labels, "copy-labels", true, "Copy the template's labels")
flags.BoolVar(&templateOpts.Topics, "copy-topics", true, "Copy template topics") flags.BoolVar(&templateOpts.Topics, "copy-topics", true, "Copy the template's topics")
flags.BoolVar(&templateOpts.Webhooks, "copy-webhooks", false, "Copy template webhooks") flags.BoolVar(&templateOpts.Webhooks, "copy-webhooks", false, "Copy the template's webhooks")
flags.StringVar(&localSource, "source", "", "Publish the local Git repository in this directory")
_ = command.MarkFlagDirname("source")
flags.StringVar(&remote, "remote-name", "origin", "Name of the new repository's remote in --source")
flags.BoolVar(&push, "push", false, "Push the current branch of --source")
_ = command.RegisterFlagCompletionFunc("org", a.completeOrgs())
_ = command.RegisterFlagCompletionFunc("template", a.completeRepos())
_ = command.RegisterFlagCompletionFunc("license", a.completeTemplates(services.Repositories.Licenses))
_ = command.RegisterFlagCompletionFunc("gitignores", commaSeparated(a.completeTemplates(services.Repositories.Gitignores)))
_ = command.RegisterFlagCompletionFunc("label-template", a.completeTemplates(services.Repositories.LabelTemplates))
return command return command
} }
// validateCreateFlags rejects combinations of repo create flags that cannot work, before any request.
func validateCreateFlags(cmd *cobra.Command, opts services.RepositoryOptions, org, team, template, localSource string, clone bool) error {
changed := func(names ...string) bool { return slices.ContainsFunc(names, cmd.Flags().Changed) }
switch {
case changed("team") && (org == "" || strings.TrimSpace(team) == ""):
return clierr.Wrap(clierr.Usage, errors.New("--team requires --org and a team name"))
case changed("template") && template == "":
return clierr.Wrap(clierr.Usage, errors.New("--template requires owner/repo or a repository URL"))
case len(opts.Description) > 2048:
return clierr.Wrap(clierr.Usage, errors.New("description cannot exceed 2048 bytes"))
case template == "" && (opts.Gitignores != "" || opts.License != "" || opts.Readme != "" || opts.DefaultBranch != "") && !opts.AutoInit:
return clierr.Wrap(clierr.Usage, errors.New("initial content and default branch options require --auto-init"))
case template != "" && changed("auto-init", "label-template", "gitignores", "license", "readme", "as-template", "trust-model", "object-format"):
return clierr.Wrap(clierr.Usage, errors.New("repository initialization options cannot be combined with --template"))
case template == "" && changed("copy-content", "copy-git-hooks", "copy-avatar", "copy-labels", "copy-topics", "copy-webhooks"):
return clierr.Wrap(clierr.Usage, errors.New("copy options require --template owner/repo"))
case localSource != "" && (clone || template != "" || opts.AutoInit):
return clierr.Wrap(clierr.Usage, errors.New("--source publishes a local repository; it cannot be combined with --clone, --template or --auto-init"))
case localSource == "" && changed("push", "remote-name"):
return clierr.Wrap(clierr.Usage, errors.New("--push and --remote-name require --source"))
}
if opts.DefaultBranch != "" {
return validateBranch(opts.DefaultBranch)
}
return nil
}
// validateTemplates checks the license, .gitignore and label templates of opts against the
// server's lists, and spells them as the server does.
func validateTemplates(ctx context.Context, service services.Repositories, opts *services.RepositoryOptions) error {
for _, field := range []struct {
value *string
kind, flag string
list func(services.Repositories, context.Context) ([]string, error)
multi bool
}{
{&opts.License, "license", "license", services.Repositories.Licenses, false},
{&opts.Gitignores, ".gitignore template", "gitignores", services.Repositories.Gitignores, true},
{&opts.Labels, "label template", "label-template", services.Repositories.LabelTemplates, false},
} {
if *field.value == "" {
continue
}
known, err := field.list(service, ctx)
if err != nil {
return err
}
names := []string{*field.value}
if field.multi {
names = strings.Split(*field.value, ",")
}
for i, name := range names {
at := slices.IndexFunc(known, func(k string) bool { return strings.EqualFold(k, strings.TrimSpace(name)) })
if at < 0 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown %s %q; complete --%s with Tab to see the server's %ss", field.kind, strings.TrimSpace(name), field.flag, field.kind))
}
names[i] = known[at]
}
*field.value = strings.Join(names, ",")
}
return nil
}
// completeTemplates completes the names that list returns, such as the server's licenses.
func (a *application) completeTemplates(list func(services.Repositories, context.Context) ([]string, error)) cobra.CompletionFunc {
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
names, err := list(services.Repositories{Client: t.client}, ctx)
return describe(names, func(name string) (string, string) { return name, "" }), err
})
}
// checkPublishSource fails unless dir is a Git working tree without a remote named remote.
func (a *application) checkPublishSource(ctx context.Context, dir, remote string) error {
if inside, _ := a.gitProbe().Output(ctx, dir, false, "rev-parse", "--is-inside-work-tree"); inside != "true" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--source %s is not a Git repository; run git init first", dir))
}
if _, err := a.gitProbe().Output(ctx, dir, false, "remote", "get-url", remote); err == nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--source %s already has a remote %s; pass --remote-name", dir, remote))
}
return nil
}
// publishRepository adds item as the remote of the local repository in dir and, with push,
// pushes the current branch to it.
func (a *application) publishRepository(cmd *cobra.Command, t target, item services.Repository, dir, remote string, push bool) error {
url, ssh, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, t.gitProtocol)
if err != nil {
return err
}
if _, err := a.gitIn(cmd.Context(), dir, "remote", "add", "--", remote, url); err != nil {
return fmt.Errorf("created %s, but could not add it as Git remote %s: %w", item.FullName, remote, err)
}
a.done(cmd, "Added Git remote %s for %s", remote, item.FullName)
if !push {
return nil
}
credentials, err := a.gitTransportArguments(t, ssh)
if err != nil {
return err
}
if _, err := a.gitIn(cmd.Context(), dir, append(credentials, "push", "--quiet", "--set-upstream", remote, "HEAD")...); err != nil {
return fmt.Errorf("created %s, but could not push to it: %w", item.FullName, err)
}
a.done(cmd, "Pushed the current branch to %s", item.FullName)
return nil
}
+67
View File
@@ -62,3 +62,70 @@ func TestRepositoryCreateClonesNewRepository(t *testing.T) {
require.Equal(t, []string{call}, runner.calls) require.Equal(t, []string{call}, runner.calls)
require.JSONEq(t, `{"id":7,"full_name":"alice/project"}`, out.String()) require.JSONEq(t, `{"id":7,"full_name":"alice/project"}`, out.String())
} }
func TestRepositoryCreateValidatesTemplatesBeforeCreating(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/api/v1/licenses":
_, _ = w.Write([]byte(`[{"key":"MIT","name":"MIT"},{"key":"Apache-2.0","name":"Apache-2.0"}]`))
case "/api/v1/gitignore/templates":
_, _ = w.Write([]byte(`["Go","Node"]`))
case "/api/v1/user/repos":
requirePayload(t, `{"name":"project","auto_init":true,"license":"MIT","gitignores":"Go,Node"}`, r.Body)
_, _ = w.Write([]byte(`{"full_name":"alice/project","html_url":"https://gitea.test/alice/project"}`))
}
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"repo", "create", "project", "--auto-init", "--license", "NOPE"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
require.ErrorContains(t, err, `unknown license "NOPE"`)
require.NotContains(t, requests, "POST /api/v1/user/repos")
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "new", "project", "--auto-init", "-l", "mit", "-g", "go,Node"}))
require.Equal(t, "https://gitea.test/alice/project\n", out.String())
require.Equal(t, "✓ Created repository alice/project\n", diagnostics.String())
}
func TestRepositoryCreatePublishesALocalRepository(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requirePayload(t, `{"name":"project"}`, r.Body)
_, _ = fmt.Fprintf(w, `{"full_name":"alice/project","html_url":"https://gitea.test/alice/project","clone_url":"http://%s/alice/project.git"}`, r.Host)
}))
defer server.Close()
deps, _, diagnostics := repoTTYDeps(t, server.URL)
dir := t.TempDir()
remote := server.URL + "/alice/project.git"
push := credentialCall(t, deps, server.URL, "alice", "push", "--quiet", "--set-upstream", "origin", "HEAD")
runner := &fakeGit{results: map[string]string{"rev-parse --is-inside-work-tree": "true", "remote add -- origin " + remote: "", push: ""}}
deps.Git = runner
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "create", "project", "--public", "--source", dir, "--push"}))
require.Equal(t, []string{"rev-parse --is-inside-work-tree", "remote get-url origin", "remote add -- origin " + remote, push}, runner.calls)
require.Equal(t, []string{dir, dir, dir, dir}, runner.dirs)
require.Equal(t, "✓ Created repository alice/project\n✓ Added Git remote origin for alice/project\n✓ Pushed the current branch to alice/project\n", diagnostics.String())
err := Execute(context.Background(), deps, []string{"repo", "create", "project", "--source", dir, "--auto-init"})
require.ErrorContains(t, err, "--source publishes a local repository")
}
func TestRepositoryMigrateNamesTheRepositoryAfterItsSource(t *testing.T) {
for _, test := range []struct{ source, want string }{
{"https://github.com/go-gitea/tea.git", "tea"},
{"https://example.com/group/project/", "project"},
{"/srv/git/legacy.git", "legacy"},
{"https://example.com/", ""},
} {
require.Equal(t, test.want, migrationName(test.source), test.source)
}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requirePayload(t, `{"repo_name":"tea","clone_addr":"https://github.com/go-gitea/tea.git","service":"git","mirror":true,"mirror_interval":"8h"}`, r.Body)
_, _ = w.Write([]byte(`{"full_name":"alice/tea","mirror":true,"html_url":"https://gitea.test/alice/tea"}`))
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "migrate", "https://github.com/go-gitea/tea.git", "--mirror", "--mirror-interval", "8h"}))
require.Equal(t, "https://gitea.test/alice/tea\n", out.String())
require.Equal(t, "✓ Imported https://github.com/go-gitea/tea.git into alice/tea (pull mirror, every 8h)\n", diagnostics.String())
err := Execute(context.Background(), deps, []string{"repo", "migrate", "https://github.com/go-gitea/tea.git", "--service", "GitHub", "--issues"})
require.ErrorContains(t, err, "importing from github needs --auth-token or --auth-token-file")
}
+44 -7
View File
@@ -12,17 +12,54 @@ import (
) )
func TestRepositoryEditPreservesExplicitClearsAndFalse(t *testing.T) { func TestRepositoryEditPreservesExplicitClearsAndFalse(t *testing.T) {
calls := 0 var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++ requests = append(requests, r.Method+" "+r.URL.Path)
require.Equal(t, http.MethodPatch, r.Method) switch r.Method + " " + r.URL.Path {
require.Equal(t, "/api/v1/repos/alice/project", r.URL.Path) case "GET /api/v1/repos/alice/project/branches/main":
requirePayload(t, `{"name":"renamed","website":"","default_branch":"main","archived":false,"private":false,"template":false,"has_actions":false}`, r.Body) _, _ = w.Write([]byte(`{"name":"main"}`))
case "PATCH /api/v1/repos/alice/project":
requirePayload(t, `{"name":"renamed","website":"","default_branch":"main","archived":false,"private":false,"template":false,"has_actions":false,"has_wiki":false,"default_merge_style":"squash"}`, r.Body)
_, _ = w.Write([]byte(`{"name":"renamed","full_name":"alice/renamed","website":"","default_branch":"main"}`)) _, _ = w.Write([]byte(`{"name":"renamed","full_name":"alice/renamed","website":"","default_branch":"main"}`))
case "GET /api/v1/repos/alice/renamed":
_, _ = w.Write([]byte(`{"name":"renamed","full_name":"alice/renamed","website":"","default_branch":"main"}`))
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
})) }))
defer server.Close() defer server.Close()
deps, out, _ := serverDeps(t, server.URL) deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "edit", "-R", "alice/project", "--name", "renamed", "--website=", "--default-branch", "main", "--archived=false", "--private=false", "--as-template=false", "--actions=false", "--json=name,website,default_branch"})) require.NoError(t, Execute(context.Background(), deps, []string{"repo", "edit", "-R", "alice/project", "--name", "renamed", "--website=", "--default-branch", "main", "--archived=false", "--private=false", "--as-template=false", "--actions=false", "--enable-wiki=false", "--default-merge-style", "squash", "--json=name,website,default_branch"}))
require.JSONEq(t, `{"name":"renamed","website":"","default_branch":"main"}`, out.String()) require.JSONEq(t, `{"name":"renamed","website":"","default_branch":"main"}`, out.String())
require.Equal(t, 1, calls) require.Equal(t, []string{"GET /api/v1/repos/alice/project/branches/main", "PATCH /api/v1/repos/alice/project", "GET /api/v1/repos/alice/renamed"}, requests)
}
func TestRepositoryEditRejectsAMissingDefaultBranch(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"repo", "edit", "alice/project", "--default-branch", "nope"})
require.EqualError(t, err, `branch "nope" not found in alice/project`)
require.Equal(t, []string{"GET /api/v1/repos/alice/project/branches/nope"}, requests)
}
func TestRepositoryEditReportsTheChangedSettings(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
if r.Method == http.MethodPatch {
requirePayload(t, `{"description":"CLI","private":true}`, r.Body)
}
_, _ = w.Write([]byte(`{"full_name":"alice/project"}`))
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "edit", "alice/project", "-d", "CLI", "--visibility", "private", "--add-topic", "cli,go", "--remove-topic", "old"}))
require.Equal(t, []string{"PATCH /api/v1/repos/alice/project", "PUT /api/v1/repos/alice/project/topics/cli", "PUT /api/v1/repos/alice/project/topics/go", "DELETE /api/v1/repos/alice/project/topics/old"}, requests)
require.Empty(t, out.String())
require.Equal(t, "✓ Edited repository alice/project (add-topic, description, remove-topic, visibility)\n", diagnostics.String())
} }
+112 -59
View File
@@ -4,13 +4,17 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt"
"net/url" "net/url"
"path" "path"
"strings"
"time" "time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -22,10 +26,40 @@ func validateMigrationURL(raw string) error {
return nil return nil
} }
// migrationName derives a repository name from the last segment of a clone URL or server path.
func migrationName(source string) string {
if parsed, err := url.Parse(source); err == nil && parsed.Path != "" {
source = parsed.Path
}
name := strings.TrimSuffix(path.Base(strings.TrimRight(source, "/")), ".git")
if validateRepositoryName(name) != nil {
return ""
}
return name
}
func (a *application) repoMigrateCommand() *cobra.Command { func (a *application) repoMigrateCommand() *cobra.Command {
var opts services.RepositoryMigrationOptions var opts services.RepositoryMigrationOptions
var passwordFile, tokenFile, serverPath string var passwordFile, tokenFile, serverPath string
command := &cobra.Command{Use: "migrate [clone-url]", Short: "Import a remote repository through Gitea", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { command := &cobra.Command{
Use: "migrate [<clone-url>]",
Short: "Import a repository from another server",
Long: `Import a repository into Gitea from a Git URL, or from another forge with --service, which also
copies its issues, labels, milestones, pull requests, releases or wiki on request. --mirror creates
a pull mirror that Gitea updates from the source instead.
The repository is named after the last segment of the URL unless --name is given. Imports from a
forge with --service need an access token for it.`,
Example: `# Import a public Git repository
$ tea repo migrate https://github.com/go-gitea/tea.git
# Mirror a repository into an organization every 8 hours
$ tea repo migrate https://github.com/go-gitea/tea.git --owner acme --mirror --mirror-interval 8h
# Import a GitHub repository with its issues and pull requests
$ tea repo migrate https://github.com/acme/tool --service github --issues --pull-requests --auth-token-file token.txt`,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo migrate URL --owner " + owner }); err != nil { if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo migrate URL --owner " + owner }); err != nil {
return err return err
} }
@@ -44,62 +78,30 @@ func (a *application) repoMigrateCommand() *cobra.Command {
} }
opts.CloneURL = serverPath opts.CloneURL = serverPath
} }
prompted := false if err := validateMigrationFlags(cmd, opts, passwordFile, tokenFile); err != nil {
for _, field := range []struct { return err
value *string }
flag string validURL := func(raw string) error { return validateMigrationURL(raw) }
}{{&opts.CloneURL, "clone-url"}, {&opts.Name, "name"}} { if cmd.Flags().Changed("server-path") {
completed, err := a.require(cmd, field.value, field.flag) validURL = nil
}
prompted, err := a.requireInput(cmd, &opts.CloneURL, "clone-url", "Clone URL", validURL)
if err != nil { if err != nil {
return err return err
} }
prompted = prompted || completed if validURL != nil {
if err := validURL(opts.CloneURL); err != nil {
return err
}
}
opts.Name = cmp.Or(opts.Name, migrationName(opts.CloneURL))
named, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", func(name string) error { return validateRepositoryName(name) })
if err != nil {
return err
} }
if err := validateRepositoryName(opts.Name); err != nil { if err := validateRepositoryName(opts.Name); err != nil {
return err return err
} }
if !cmd.Flags().Changed("server-path") {
if err := validateMigrationURL(opts.CloneURL); err != nil {
return err
}
}
if cmd.Flags().Changed("server-path") && (opts.Service != "git" || opts.AuthUser != "" || cmd.Flags().Changed("auth-password") || cmd.Flags().Changed("auth-password-file") || cmd.Flags().Changed("auth-token") || cmd.Flags().Changed("auth-token-file")) {
return clierr.Wrap(clierr.Usage, errors.New("server path imports use --service git without remote credentials"))
}
switch opts.Service {
case "git", "gitea", "gitlab", "gogs", "github":
default:
return clierr.Wrap(clierr.Usage, errors.New("--service must be git, gitea, gitlab, gogs, or github"))
}
if opts.Timeout <= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--timeout must be positive"))
}
if len(opts.Description) > 2048 {
return clierr.Wrap(clierr.Usage, errors.New("description cannot exceed 2048 bytes"))
}
if opts.LFSEndpoint != "" {
if !opts.LFS {
return clierr.Wrap(clierr.Usage, errors.New("--lfs-endpoint requires --lfs"))
}
if err := validateMigrationURL(opts.LFSEndpoint); err != nil {
return err
}
}
if opts.MirrorInterval != "" {
interval, err := time.ParseDuration(opts.MirrorInterval)
if err != nil || interval <= 0 || !opts.Mirror {
return clierr.Wrap(clierr.Usage, errors.New("--mirror-interval requires --mirror and a positive duration"))
}
}
if opts.Mirror && (opts.Issues || opts.Milestones || opts.Labels || opts.PullRequests || opts.Releases) {
return clierr.Wrap(clierr.Usage, errors.New("mirrors cannot copy issues, milestones, labels, pull requests, or releases"))
}
if opts.Service == "git" && (opts.Issues || opts.Milestones || opts.Labels || opts.PullRequests || opts.Releases) {
return clierr.Wrap(clierr.Usage, errors.New("plain Git migration cannot copy forge metadata; select the source --service"))
}
if passwordFile == "-" && tokenFile == "-" {
return clierr.Wrap(clierr.Usage, errors.New("stdin can supply only one source credential"))
}
for _, field := range []struct { for _, field := range []struct {
value *string value *string
file, flag string file, flag string
@@ -117,14 +119,15 @@ func (a *application) repoMigrateCommand() *cobra.Command {
return clierr.Wrap(clierr.Usage, errors.New("--auth-password requires --auth-user")) return clierr.Wrap(clierr.Usage, errors.New("--auth-password requires --auth-user"))
} }
if opts.Service != "git" && opts.AuthToken == "" { if opts.Service != "git" && opts.AuthToken == "" {
return clierr.Wrap(clierr.Usage, errors.New("forge migration requires --auth-token or --auth-token-file")) return clierr.Wrap(clierr.Usage, fmt.Errorf("importing from %s needs --auth-token or --auth-token-file; use --service git to import only the Git data", opts.Service))
} }
target, err := a.resolve(cmd.Context(), false) target, err := a.resolve(cmd.Context(), false)
if err != nil { if err != nil {
return err return err
} }
if prompted { destination := cmp.Or(opts.Owner, target.User) + "/" + opts.Name
if err := a.confirm(cmd, "Migrate repository "+opts.Name+" to "+target.Host); err != nil { if prompted || named {
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("Import %s into %s on %s?", opts.CloneURL, destination, target.Host), DefaultYes: true}); err != nil {
return err return err
} }
} }
@@ -132,16 +135,30 @@ func (a *application) repoMigrateCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
return writeRecord(a, cmd, item, repositoryRecord) detail := ""
}} switch {
case item.Mirror && opts.MirrorInterval != "":
detail = " (pull mirror, every " + opts.MirrorInterval + ")"
case item.Mirror:
detail = " (pull mirror)"
}
a.done(cmd, "Imported %s into %s%s", opts.CloneURL, item.FullName, detail)
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return a.printURL(cmd, item.URL)
},
}
flags := command.Flags() flags := command.Flags()
flags.DurationVar(&opts.Timeout, "timeout", 10*time.Minute, "Maximum migration request duration; Gitea may continue an import after cancellation") flags.DurationVar(&opts.Timeout, "timeout", 10*time.Minute, "Maximum migration request duration; Gitea may continue an import after cancellation")
flags.StringVar(&opts.Name, "name", "", "Destination repository name") flags.StringVar(&opts.Name, "name", "", "Destination repository name (default: the source's name)")
flags.StringVar(&opts.Owner, "owner", "", "Destination owner (default: current account)") flags.StringVar(&opts.Owner, "owner", "", "Destination user or organization (default: your account)")
flags.StringVar(&serverPath, "server-path", "", "Absolute source Git repository path on the Gitea server (requires server permission)") flags.StringVar(&serverPath, "server-path", "", "Absolute source Git repository path on the Gitea server (requires server permission)")
_ = command.MarkFlagDirname("server-path")
flags.StringVar(&opts.CloneURL, "clone-url", "", "Remote repository URL") flags.StringVar(&opts.CloneURL, "clone-url", "", "Remote repository URL")
flags.StringVar(&opts.Service, "service", "git", "Source service: git, gitea, gitlab, gogs, github") _ = flags.MarkHidden("clone-url")
flags.StringVar(&opts.Description, "description", "", "Repository description") enumFlag(command, &opts.Service, "service", "", "git", "Source service", "git", "gitea", "gitlab", "gogs", "github")
flags.StringVarP(&opts.Description, "description", "d", "", "Repository description")
flags.BoolVar(&opts.Mirror, "mirror", false, "Create a pull mirror") flags.BoolVar(&opts.Mirror, "mirror", false, "Create a pull mirror")
flags.BoolVar(&opts.Private, "private", false, "Make the imported repository private") flags.BoolVar(&opts.Private, "private", false, "Make the imported repository private")
flags.BoolVar(&opts.Wiki, "wiki", false, "Copy wiki") flags.BoolVar(&opts.Wiki, "wiki", false, "Copy wiki")
@@ -158,5 +175,41 @@ func (a *application) repoMigrateCommand() *cobra.Command {
flags.StringVar(&passwordFile, "auth-password-file", "", "Read source password from file or -") flags.StringVar(&passwordFile, "auth-password-file", "", "Read source password from file or -")
flags.StringVar(&opts.AuthToken, "auth-token", "", "Source API token (prefer file input)") flags.StringVar(&opts.AuthToken, "auth-token", "", "Source API token (prefer file input)")
flags.StringVar(&tokenFile, "auth-token-file", "", "Read source API token from file or -") flags.StringVar(&tokenFile, "auth-token-file", "", "Read source API token from file or -")
_ = command.RegisterFlagCompletionFunc("owner", a.completeOrgs())
return command return command
} }
// validateMigrationFlags rejects combinations of repo migrate flags that cannot work, before any
// prompt or request.
func validateMigrationFlags(cmd *cobra.Command, opts services.RepositoryMigrationOptions, passwordFile, tokenFile string) error {
changed := cmd.Flags().Changed
metadata := opts.Issues || opts.Milestones || opts.Labels || opts.PullRequests || opts.Releases
switch {
case changed("server-path") && (opts.Service != "git" || opts.AuthUser != "" || changed("auth-password") || changed("auth-password-file") || changed("auth-token") || changed("auth-token-file")):
return clierr.Wrap(clierr.Usage, errors.New("server path imports use --service git without remote credentials"))
case opts.Timeout <= 0:
return clierr.Wrap(clierr.Usage, errors.New("--timeout must be positive"))
case len(opts.Description) > 2048:
return clierr.Wrap(clierr.Usage, errors.New("description cannot exceed 2048 bytes"))
case opts.LFSEndpoint != "" && !opts.LFS:
return clierr.Wrap(clierr.Usage, errors.New("--lfs-endpoint requires --lfs"))
case opts.Mirror && metadata:
return clierr.Wrap(clierr.Usage, errors.New("mirrors cannot copy issues, milestones, labels, pull requests, or releases"))
case opts.Service == "git" && metadata:
return clierr.Wrap(clierr.Usage, errors.New("plain Git migration cannot copy forge metadata; select the source --service"))
case passwordFile == "-" && tokenFile == "-":
return clierr.Wrap(clierr.Usage, errors.New("stdin can supply only one source credential"))
}
if opts.LFSEndpoint != "" {
if err := validateMigrationURL(opts.LFSEndpoint); err != nil {
return err
}
}
if opts.MirrorInterval != "" {
interval, err := time.ParseDuration(opts.MirrorInterval)
if err != nil || interval <= 0 || !opts.Mirror {
return clierr.Wrap(clierr.Usage, errors.New("--mirror-interval requires --mirror and a positive duration"))
}
}
return nil
}
+3 -3
View File
@@ -154,7 +154,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"repo", "create", "name", "--name", "other"}, "supply name as an argument or --name, not both"}, {[]string{"repo", "create", "name", "--name", "other"}, "supply name as an argument or --name, not both"},
{[]string{"repo", "create", "name", "--default-branch", "bad..branch", "--auto-init"}, "invalid branch name"}, {[]string{"repo", "create", "name", "--default-branch", "bad..branch", "--auto-init"}, "invalid branch name"},
{[]string{"repo", "create", "name", "--license", "MIT"}, "initial content and default branch options require --auto-init"}, {[]string{"repo", "create", "name", "--license", "MIT"}, "initial content and default branch options require --auto-init"},
{[]string{"repo", "create", "name", "--trust-model", "unknown"}, "--trust-model must be committer, collaborator, or collaborator+committer"}, {[]string{"repo", "create", "name", "--trust-model", "unknown"}, "--trust-model must be default, collaborator, committer, or collaboratorcommitter"},
{[]string{"repo", "create", "name", "--object-format", "unknown"}, "--object-format must be sha1 or sha256"}, {[]string{"repo", "create", "name", "--object-format", "unknown"}, "--object-format must be sha1 or sha256"},
{[]string{"repo", "create", "name", "--team", "developers"}, "--team requires --org and a team name"}, {[]string{"repo", "create", "name", "--team", "developers"}, "--team requires --org and a team name"},
{[]string{"repo", "create", "name", "--org", "acme", "--team="}, "--team requires --org and a team name"}, {[]string{"repo", "create", "name", "--org", "acme", "--team="}, "--team requires --org and a team name"},
@@ -162,8 +162,8 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"repo", "create", "name", "--template="}, "--template requires owner/repo or a repository URL"}, {[]string{"repo", "create", "name", "--template="}, "--template requires owner/repo or a repository URL"},
{[]string{"repo", "create", "name", "--template", "owner/source", "--auto-init"}, "repository initialization options cannot be combined with --template"}, {[]string{"repo", "create", "name", "--template", "owner/source", "--auto-init"}, "repository initialization options cannot be combined with --template"},
{[]string{"repo", "create", "name", "--template", "owner/source", "-R", "owner/other"}, "--repo is not used by repo create; did you mean: tea repo create owner/name"}, {[]string{"repo", "create", "name", "--template", "owner/source", "-R", "owner/other"}, "--repo is not used by repo create; did you mean: tea repo create owner/name"},
{[]string{"repo", "edit", "--name="}, "repository name cannot be empty"}, {[]string{"repo", "edit", "--name="}, "--name cannot be empty"},
{[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"}, {[]string{"repo", "edit", "--default-branch="}, "--default-branch cannot be empty"},
{[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"}, {[]string{"repo", "archive", "alice/project", "-R", "alice/other", "--yes"}, "supply the repository as an argument or --repo, not both"},
{[]string{"repo", "view", "alice/project", "--repo", "alice/other"}, "supply the repository as an argument or --repo, not both"}, {[]string{"repo", "view", "alice/project", "--repo", "alice/other"}, "supply the repository as an argument or --repo, not both"},
{[]string{"repo", "view", "alice/project", "alice/other"}, "too many arguments\nUsage: tea repo view [<repository>] [flags]"}, {[]string{"repo", "view", "alice/project", "alice/other"}, "too many arguments\nUsage: tea repo view [<repository>] [flags]"},
+31
View File
@@ -72,6 +72,15 @@ type RepositoryEdit struct {
Private *bool `json:"private,omitempty"` Private *bool `json:"private,omitempty"`
Archived *bool `json:"archived,omitempty"` Archived *bool `json:"archived,omitempty"`
Template *bool `json:"template,omitempty"` Template *bool `json:"template,omitempty"`
Issues *bool `json:"has_issues,omitempty"`
Wiki *bool `json:"has_wiki,omitempty"`
PullRequests *bool `json:"has_pull_requests,omitempty"`
Projects *bool `json:"has_projects,omitempty"`
MergeCommits *bool `json:"allow_merge_commits,omitempty"`
Squash *bool `json:"allow_squash_merge,omitempty"`
Rebase *bool `json:"allow_rebase,omitempty"`
MergeStyle *string `json:"default_merge_style,omitempty"`
DeleteBranch *bool `json:"default_delete_branch_after_merge,omitempty"`
} }
// View retrieves one repository. // View retrieves one repository.
@@ -169,14 +178,36 @@ func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOpt
// Edit updates only explicitly supplied resource fields. // Edit updates only explicitly supplied resource fields.
func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) { func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) {
var mergeStyle *gitea.MergeStyle
if opts.MergeStyle != nil {
style := gitea.MergeStyle(*opts.MergeStyle)
mergeStyle = &style
}
return repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) { return repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
return c.Repositories.EditRepo(ctx, owner, name, gitea.EditRepoOption{ return c.Repositories.EditRepo(ctx, owner, name, gitea.EditRepoOption{
HasActions: opts.ActionsEnabled, Name: opts.Name, Website: opts.Website, DefaultBranch: opts.DefaultBranch, HasActions: opts.ActionsEnabled, Name: opts.Name, Website: opts.Website, DefaultBranch: opts.DefaultBranch,
Description: opts.Description, Private: opts.Private, Archived: opts.Archived, Template: opts.Template, Description: opts.Description, Private: opts.Private, Archived: opts.Archived, Template: opts.Template,
HasIssues: opts.Issues, HasWiki: opts.Wiki, HasPullRequests: opts.PullRequests, HasProjects: opts.Projects,
AllowMerge: opts.MergeCommits, AllowSquash: opts.Squash, AllowRebase: opts.Rebase, DefaultMergeStyle: mergeStyle,
DefaultDeleteBranchAfterMerge: opts.DeleteBranch,
}) })
}) })
} }
// AddTopic adds a topic to the repository.
func (s Repositories) AddTopic(ctx context.Context, repo, topic string) error {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
return c.Repositories.AddRepoTopic(ctx, owner, name, topic)
})
}
// RemoveTopic removes a topic from the repository.
func (s Repositories) RemoveTopic(ctx context.Context, repo, topic string) error {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
return c.Repositories.DeleteRepoTopic(ctx, owner, name, topic)
})
}
// Delete removes the selected repository. // Delete removes the selected repository.
func (s Repositories) Delete(ctx context.Context, repo string) error { func (s Repositories) Delete(ctx context.Context, repo string) error {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
+24
View File
@@ -45,6 +45,30 @@ func (s Repositories) Generate(ctx context.Context, source string, opts Reposito
return repositoryFrom(&response), nil return repositoryFrom(&response), nil
} }
// Licenses lists the license templates that repository creation accepts.
func (s Repositories) Licenses(ctx context.Context) ([]string, error) {
licenses, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.LicensesTemplateListEntry, *gitea.Response, error) {
return c.Templates.ListLicenses(ctx)
})
return convertAll(licenses, func(license *gitea.LicensesTemplateListEntry) string { return license.Key }), err
}
// Gitignores lists the .gitignore templates that repository creation accepts.
func (s Repositories) Gitignores(ctx context.Context) ([]string, error) {
names, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]string, *gitea.Response, error) {
return c.Templates.ListGitignores(ctx)
})
return names, err
}
// LabelTemplates lists the label sets that repository creation accepts.
func (s Repositories) LabelTemplates(ctx context.Context) ([]string, error) {
names, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]string, *gitea.Response, error) {
return c.Templates.ListLabels(ctx)
})
return names, err
}
// RepositoryMigrationOptions is explicit server-side import input. Credentials stay in request bodies. // RepositoryMigrationOptions is explicit server-side import input. Credentials stay in request bodies.
type RepositoryMigrationOptions struct { type RepositoryMigrationOptions struct {
Name string `json:"repo_name"` Name string `json:"repo_name"`