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
+742 -308

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
+152 -67
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 {
if err := a.repositoryArgument(args); err != nil {
return err
}
options := services.RepositoryEdit{}
if cmd.Flags().Changed("name") {
if strings.TrimSpace(repoName) == "" {
return clierr.Wrap(clierr.Usage, errors.New("repository name cannot be empty"))
}
options.Name = &repoName
}
if cmd.Flags().Changed("website") {
options.Website = &website
}
if cmd.Flags().Changed("default-branch") {
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") { // repositorySwitches are the on/off settings of repo edit: each flag sets one field, and =false
options.Description = &description // turns the setting off.
} var repositorySwitches = []struct {
if cmd.Flags().Changed("private") { flag, usage string
options.Private = &private field func(*services.RepositoryEdit) **bool
} }{
if cmd.Flags().Changed("as-template") { {"private", "Make the repository private (=false makes it public)", func(e *services.RepositoryEdit) **bool { return &e.Private }},
options.Template = &template {"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 }},
if cmd.Flags().Changed("actions") { {"enable-wiki", "Enable the wiki", func(e *services.RepositoryEdit) **bool { return &e.Wiki }},
options.ActionsEnabled = &actionsEnabled {"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 }},
if options == (services.RepositoryEdit{}) { {"actions", "Enable Actions", func(e *services.RepositoryEdit) **bool { return &e.ActionsEnabled }},
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea repo edit --help")) {"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 }},
t, err := a.resolve(cmd.Context(), true) {"allow-rebase", "Allow rebase merging pull requests", func(e *services.RepositoryEdit) **bool { return &e.Rebase }},
if err != nil { {"delete-branch-on-merge", "Delete head branches after merging pull requests", func(e *services.RepositoryEdit) **bool { return &e.DeleteBranch }},
return err {"archived", "Archive the repository (=false unarchives it)", func(e *services.RepositoryEdit) **bool { return &e.Archived }},
} }
if repoArchived {
if err := a.confirm(cmd, "Archive repository "+t.Repo); err != nil { 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 {
return err return err
} }
} changed := cmd.Flags().Changed
item, err := (services.Repositories{Client: t.client}).Edit(cmd.Context(), t.Repo, options) options := services.RepositoryEdit{}
if err != nil { for i, toggle := range repositorySwitches {
return err if changed(toggle.flag) {
} *toggle.field(&options) = &switches[i]
return writeRecord(a, cmd, item, repositoryRecord) }
}} }
edit.Flags().StringVar(&repoName, "name", "", "Rename the repository") for _, field := range []struct {
edit.Flags().StringVar(&website, "website", "", "Repository website; empty clears") flag string
edit.Flags().StringVar(&defaultBranch, "default-branch", "", "Default branch name") value *string
edit.Flags().BoolVar(&repoArchived, "archived", false, "Archive repository; =false restores it") into **string
edit.Flags().StringVar(&description, "description", "", "Repository description") }{{"name", &name, &options.Name}, {"default-branch", &defaultBranch, &options.DefaultBranch}, {"default-merge-style", &mergeStyle, &options.MergeStyle}} {
edit.Flags().BoolVar(&private, "private", false, "Make repository private (use --private=false to make public)") if changed(field.flag) {
edit.Flags().BoolVar(&template, "as-template", false, "Make repository a template (use =false to disable)") if strings.TrimSpace(*field.value) == "" {
edit.Flags().BoolVar(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)") return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", field.flag))
group.AddCommand(edit, a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand()) }
*field.into = field.value
}
}
if changed("website") {
options.Website = &website
}
if changed("description") {
options.Description = &description
}
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
}
if err := requireNames("topic", "", addTopics, removeTopics); err != nil {
return err
}
if options == (services.RepositoryEdit{}) && len(addTopics)+len(removeTopics) == 0 {
return clierr.Wrap(clierr.Usage, errors.New("supply a setting to change; see tea repo edit --help"))
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Repositories{Client: t.client}
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
}
}
repo := t.Repo
if options != (services.RepositoryEdit{}) {
item, err := service.Edit(cmd.Context(), t.Repo, options)
if err != nil {
return err
}
repo = item.FullName
}
for _, topic := range addTopics {
if err := service.AddTopic(cmd.Context(), repo, topic); err != nil {
return fmt.Errorf("add topic %q: %w", topic, err)
}
}
for _, topic := range removeTopics {
if err := service.RemoveTopic(cmd.Context(), repo, topic); err != nil {
return fmt.Errorf("remove topic %q: %w", topic, err)
}
}
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 {
+266 -126
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,147 +28,283 @@ 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{
if len(args) > 0 { Use: "create [<name>]",
if cmd.Flags().Changed("name") { Aliases: []string{"new"},
return clierr.Wrap(clierr.Usage, errors.New("supply name as an argument or --name, not both")) 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 cmd.Flags().Changed("name") {
return clierr.Wrap(clierr.Usage, errors.New("supply name as an argument or --name, not both"))
}
opts.Name = args[0]
} }
opts.Name = args[0] if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo create " + owner + "/" + cmp.Or(opts.Name, "NAME") }); err != nil {
}
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo create " + owner + "/" + cmp.Or(opts.Name, "NAME") }); err != nil {
return err
}
if owner, name, ok := strings.Cut(opts.Name, "/"); ok {
if cmd.Flags().Changed("org") {
return clierr.Wrap(clierr.Usage, errors.New("supply the owner as OWNER/NAME or --org, not both"))
}
org, opts.Name = owner, name
}
if cmd.Flags().Changed("team") && (org == "" || strings.TrimSpace(team) == "") {
return clierr.Wrap(clierr.Usage, errors.New("--team requires --org and a team name"))
}
if cmd.Flags().Changed("template") && source == "" {
return clierr.Wrap(clierr.Usage, errors.New("--template requires owner/repo or a repository URL"))
}
prompted, err := a.require(cmd, &opts.Name, "name")
if err != nil {
return err
}
if err := validateRepositoryName(opts.Name); err != nil {
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 return err
} }
} if owner, name, ok := strings.Cut(opts.Name, "/"); ok {
switch opts.TrustModel { if cmd.Flags().Changed("org") {
case "", "committer", "collaborator", "collaborator+committer": return clierr.Wrap(clierr.Usage, errors.New("supply the owner as OWNER/NAME or --org, not both"))
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
if source != "" {
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"))
} }
org, opts.Name = owner, name
} }
selected.options.repo = source if err := validateCreateFlags(cmd, opts, org, team, template, localSource, clone); err != nil {
} 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)
if err != nil {
return err
}
if !cmd.Flags().Changed("org") && strings.EqualFold(org, target.User) {
if team != "" {
return clierr.Wrap(clierr.Usage, errors.New("--team requires a repository owned by an organization"))
}
org = ""
}
if prompted {
if err := a.confirm(cmd, "Create repository "+opts.Name+" on "+target.Host); err != nil {
return err return err
} }
} if public {
var teamID int64 if cmd.Flags().Changed("private") {
if team != "" { return clierr.Wrap(clierr.Usage, errors.New("--public and --private cannot be combined"))
teams, err := (services.Teams{Client: target.client}).ByName(cmd.Context(), org) }
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
} }
match, ok := teams[strings.ToLower(team)] if err := validateRepositoryName(opts.Name); err != nil {
if !ok { return err
return clierr.Wrap(clierr.Usage, fmt.Errorf("team %q not found in organization %s", team, org))
} }
teamID = match.ID selected := *a
} selected.options.repo = template
service := services.Repositories{Client: target.client} target, err := selected.resolve(cmd.Context(), false)
var item services.Repository if err != nil {
if source != "" { return err
templateOpts.Name = opts.Name }
templateOpts.Owner = org if !cmd.Flags().Changed("org") && strings.EqualFold(org, target.User) {
templateOpts.Description = opts.Description if team != "" {
templateOpts.Private = opts.Private return clierr.Wrap(clierr.Usage, errors.New("--team requires a repository owned by an organization"))
templateOpts.DefaultBranch = opts.DefaultBranch }
item, err = service.Generate(cmd.Context(), target.Repo, templateOpts) org = ""
} else { }
item, err = service.Create(cmd.Context(), org, opts) service := services.Repositories{Client: target.client}
if err := validateTemplates(cmd.Context(), service, &opts); err != nil {
return err
}
if prompted {
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
}
}
var teamID int64
if team != "" {
teams, err := (services.Teams{Client: target.client}).ByName(cmd.Context(), org)
if err != nil {
return err
}
match, ok := teams[strings.ToLower(team)]
if !ok {
return clierr.Wrap(clierr.Usage, fmt.Errorf("team %q not found in organization %s", team, org))
}
teamID = match.ID
}
var item services.Repository
if template != "" {
templateOpts.Name = opts.Name
templateOpts.Owner = org
templateOpts.Description = opts.Description
templateOpts.Private = opts.Private
templateOpts.DefaultBranch = opts.DefaultBranch
item, err = service.Generate(cmd.Context(), target.Repo, templateOpts)
} else {
item, err = service.Create(cmd.Context(), org, opts)
}
if err != nil {
return err
}
a.done(cmd, "Created repository %s", item.FullName)
if teamID != 0 {
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)
}
a.done(cmd, "Granted team %s access to %s", team, item.FullName)
}
target.Repo = item.FullName
switch {
case clone:
_, 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
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return a.printURL(cmd, item.URL)
},
}
flags := command.Flags()
flags.StringVar(&opts.Name, "name", "", "Repository name")
_ = flags.MarkHidden("name")
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.StringVarP(&opts.Description, "description", "d", "", "Repository description")
flags.BoolVar(&opts.Private, "private", false, "Make the repository private")
flags.BoolVar(&public, "public", false, "Make the repository public (the default)")
flags.BoolVar(&opts.AutoInit, "auto-init", false, "Add a first commit with a README")
flags.StringVar(&opts.DefaultBranch, "default-branch", "", "Name of the default branch")
flags.StringVar(&opts.Labels, "label-template", "", "Add the labels of this label set")
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.BoolVar(&opts.Template, "as-template", false, "Make the new repository a template")
enumFlag(command, &opts.TrustModel, "trust-model", "", "", "Signature trust model", "default", "collaborator", "committer", "collaboratorcommitter")
enumFlag(command, &opts.ObjectFormat, "object-format", "", "", "Git object format", "sha1", "sha256")
flags.StringVar(&template, "template", "", "Copy this template repository (OWNER/REPO or URL)")
flags.BoolVar(&templateOpts.Content, "copy-content", true, "Copy the template's Git content")
flags.BoolVar(&templateOpts.GitHooks, "copy-git-hooks", true, "Copy the template's Git hooks")
flags.BoolVar(&templateOpts.Avatar, "copy-avatar", true, "Copy the template's avatar")
flags.BoolVar(&templateOpts.Labels, "copy-labels", true, "Copy the template's labels")
flags.BoolVar(&templateOpts.Topics, "copy-topics", true, "Copy the template's topics")
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
}
// 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 { if err != nil {
return err return err
} }
if teamID != 0 { names := []string{*field.value}
if err := (services.Teams{Client: target.client}).SetRepository(cmd.Context(), teamID, item.FullName, true); err != nil { if field.multi {
return fmt.Errorf("created %s, but could not grant team %q access: %w", item.FullName, team, err) 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]
} }
if err := writeRecord(a, cmd, item, repositoryRecord); err != nil || !clone { *field.value = strings.Join(names, ",")
return err }
} return nil
target.Repo = item.FullName }
_, err = a.cloneRepository(cmd, target, item, cloneOptions{upstream: "upstream"})
return err // 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 {
flags := command.Flags() return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
flags.StringVar(&opts.Name, "name", "", "Repository name") names, err := list(services.Repositories{Client: t.client}, ctx)
flags.StringVar(&org, "org", "", "Create under this organization") return describe(names, func(name string) (string, string) { return name, "" }), err
flags.StringVar(&team, "team", "", "Grant this organization team access (requires --org)") })
flags.BoolVar(&clone, "clone", false, "Clone the new repository into the current directory") }
flags.StringVar(&opts.Description, "description", "", "Repository description")
flags.BoolVar(&opts.Private, "private", false, "Create a private repository") // checkPublishSource fails unless dir is a Git working tree without a remote named remote.
flags.BoolVar(&opts.AutoInit, "auto-init", false, "Initialize repository with a README") func (a *application) checkPublishSource(ctx context.Context, dir, remote string) error {
flags.StringVar(&opts.DefaultBranch, "default-branch", "", "Initial default branch") if inside, _ := a.gitProbe().Output(ctx, dir, false, "rev-parse", "--is-inside-work-tree"); inside != "true" {
flags.StringVar(&opts.Labels, "label-template", "", "Named Gitea label set") return clierr.Wrap(clierr.Usage, fmt.Errorf("--source %s is not a Git repository; run git init first", dir))
flags.StringVar(&opts.Gitignores, "gitignores", "", "Comma-separated gitignore templates (requires --auto-init)") }
flags.StringVar(&opts.License, "license", "", "License template (requires --auto-init)") if _, err := a.gitProbe().Output(ctx, dir, false, "remote", "get-url", remote); err == nil {
flags.StringVar(&opts.Readme, "readme", "", "README template (requires --auto-init)") return clierr.Wrap(clierr.Usage, fmt.Errorf("--source %s already has a remote %s; pass --remote-name", dir, remote))
flags.BoolVar(&opts.Template, "as-template", false, "Make the new repository a reusable template") }
flags.StringVar(&opts.TrustModel, "trust-model", "", "Signature trust model") return nil
flags.StringVar(&opts.ObjectFormat, "object-format", "", "Git object format: sha1 or sha256") }
flags.StringVar(&source, "template", "", "Source template owner/repo or repository URL")
flags.BoolVar(&templateOpts.Content, "copy-content", true, "Copy template Git content") // publishRepository adds item as the remote of the local repository in dir and, with push,
flags.BoolVar(&templateOpts.GitHooks, "copy-git-hooks", true, "Copy template Git hooks") // pushes the current branch to it.
flags.BoolVar(&templateOpts.Avatar, "copy-avatar", true, "Copy template avatar") func (a *application) publishRepository(cmd *cobra.Command, t target, item services.Repository, dir, remote string, push bool) error {
flags.BoolVar(&templateOpts.Labels, "copy-labels", true, "Copy template labels") url, ssh, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, t.gitProtocol)
flags.BoolVar(&templateOpts.Topics, "copy-topics", true, "Copy template topics") if err != nil {
flags.BoolVar(&templateOpts.Webhooks, "copy-webhooks", false, "Copy template webhooks") return err
return command }
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")
}
+45 -8
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"}`))
_, _ = w.Write([]byte(`{"name":"renamed","full_name":"alice/renamed","website":"","default_branch":"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"}`))
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())
} }
+154 -101
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,126 +26,139 @@ 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{
if err := a.rejectRepoFlag(cmd, func(owner string) string { return "tea repo migrate URL --owner " + owner }); err != nil { Use: "migrate [<clone-url>]",
return err 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
if len(args) > 0 { copies its issues, labels, milestones, pull requests, releases or wiki on request. --mirror creates
if cmd.Flags().Changed("clone-url") { a pull mirror that Gitea updates from the source instead.
return clierr.Wrap(clierr.Usage, errors.New("supply clone URL as an argument or --clone-url, not both"))
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 {
return err
} }
opts.CloneURL = args[0] if len(args) > 0 {
} if cmd.Flags().Changed("clone-url") {
if cmd.Flags().Changed("server-path") { return clierr.Wrap(clierr.Usage, errors.New("supply clone URL as an argument or --clone-url, not both"))
if cmd.Flags().Changed("clone-url") || len(args) > 0 { }
return clierr.Wrap(clierr.Usage, errors.New("--server-path and clone URL inputs are mutually exclusive")) opts.CloneURL = args[0]
} }
if !path.IsAbs(serverPath) { if cmd.Flags().Changed("server-path") {
return clierr.Wrap(clierr.Usage, errors.New("--server-path must be an absolute path on the Gitea server")) if cmd.Flags().Changed("clone-url") || len(args) > 0 {
return clierr.Wrap(clierr.Usage, errors.New("--server-path and clone URL inputs are mutually exclusive"))
}
if !path.IsAbs(serverPath) {
return clierr.Wrap(clierr.Usage, errors.New("--server-path must be an absolute path on the Gitea server"))
}
opts.CloneURL = serverPath
} }
opts.CloneURL = serverPath if err := validateMigrationFlags(cmd, opts, passwordFile, tokenFile); err != nil {
} return err
prompted := false }
for _, field := range []struct { validURL := func(raw string) error { return validateMigrationURL(raw) }
value *string if cmd.Flags().Changed("server-path") {
flag string validURL = nil
}{{&opts.CloneURL, "clone-url"}, {&opts.Name, "name"}} { }
completed, err := a.require(cmd, field.value, field.flag) 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 {
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
} }
} opts.Name = cmp.Or(opts.Name, migrationName(opts.CloneURL))
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")) { named, err := a.requireInput(cmd, &opts.Name, "name", "Repository name", func(name string) error { return validateRepositoryName(name) })
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 {
value *string
file, flag string
}{{&opts.AuthPassword, passwordFile, "auth-password"}, {&opts.AuthToken, tokenFile, "auth-token"}} {
value, err := a.readSecret(cmd, *field.value, field.file, field.flag, field.flag+"-file")
if err != nil { if err != nil {
return err return err
} }
*field.value = value if err := validateRepositoryName(opts.Name); err != nil {
}
if opts.AuthToken != "" && (opts.AuthUser != "" || opts.AuthPassword != "") {
return clierr.Wrap(clierr.Usage, errors.New("source token and username/password authentication are mutually exclusive"))
}
if opts.AuthPassword != "" && opts.AuthUser == "" {
return clierr.Wrap(clierr.Usage, errors.New("--auth-password requires --auth-user"))
}
if opts.Service != "git" && opts.AuthToken == "" {
return clierr.Wrap(clierr.Usage, errors.New("forge migration requires --auth-token or --auth-token-file"))
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
if prompted {
if err := a.confirm(cmd, "Migrate repository "+opts.Name+" to "+target.Host); err != nil {
return err return err
} }
} for _, field := range []struct {
item, err := (services.Repositories{Client: target.client}).Migrate(cmd.Context(), opts) value *string
if err != nil { file, flag string
return err }{{&opts.AuthPassword, passwordFile, "auth-password"}, {&opts.AuthToken, tokenFile, "auth-token"}} {
} value, err := a.readSecret(cmd, *field.value, field.file, field.flag, field.flag+"-file")
return writeRecord(a, cmd, item, repositoryRecord) if err != nil {
}} return err
}
*field.value = value
}
if opts.AuthToken != "" && (opts.AuthUser != "" || opts.AuthPassword != "") {
return clierr.Wrap(clierr.Usage, errors.New("source token and username/password authentication are mutually exclusive"))
}
if opts.AuthPassword != "" && opts.AuthUser == "" {
return clierr.Wrap(clierr.Usage, errors.New("--auth-password requires --auth-user"))
}
if opts.Service != "git" && opts.AuthToken == "" {
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)
if err != nil {
return err
}
destination := cmp.Or(opts.Owner, target.User) + "/" + opts.Name
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
}
}
item, err := (services.Repositories{Client: target.client}).Migrate(cmd.Context(), opts)
if err != nil {
return err
}
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"`