mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
9 files changed
+619
-185
No files matched your search
@@ -505,9 +505,6 @@ const (
|
||||
release list
|
||||
release publish
|
||||
release view
|
||||
repo create
|
||||
repo edit
|
||||
repo migrate
|
||||
search issue
|
||||
search pr
|
||||
status
|
||||
|
||||
+131
-46
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
@@ -16,12 +17,9 @@ import (
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"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) {
|
||||
remote = cloneURL
|
||||
if protocol == "ssh" {
|
||||
@@ -73,72 +71,159 @@ func mirrorSourceURL(item services.Repository) string {
|
||||
}
|
||||
|
||||
func (a *application) addRepositoryMutations(group *cobra.Command) {
|
||||
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand(), a.repoForkCommand(), a.repoCloneCommand())
|
||||
var description, repoName, website, defaultBranch string
|
||||
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 {
|
||||
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand(), a.repoForkCommand(), a.repoCloneCommand(), a.repoEditCommand())
|
||||
group.AddCommand(a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand())
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
changed := cmd.Flags().Changed
|
||||
options := services.RepositoryEdit{}
|
||||
if cmd.Flags().Changed("name") {
|
||||
if strings.TrimSpace(repoName) == "" {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("repository name cannot be empty"))
|
||||
for i, toggle := range repositorySwitches {
|
||||
if changed(toggle.flag) {
|
||||
*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
|
||||
}
|
||||
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") {
|
||||
if changed("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
|
||||
}
|
||||
if cmd.Flags().Changed("as-template") {
|
||||
options.Template = &template
|
||||
if err := requireNames("topic", "", addTopics, removeTopics); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("actions") {
|
||||
options.ActionsEnabled = &actionsEnabled
|
||||
}
|
||||
if options == (services.RepositoryEdit{}) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea repo edit --help"))
|
||||
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
|
||||
}
|
||||
if repoArchived {
|
||||
if err := a.confirm(cmd, "Archive repository "+t.Repo); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return writeRecord(a, cmd, item, repositoryRecord)
|
||||
}}
|
||||
edit.Flags().StringVar(&repoName, "name", "", "Rename the repository")
|
||||
edit.Flags().StringVar(&website, "website", "", "Repository website; empty clears")
|
||||
edit.Flags().StringVar(&defaultBranch, "default-branch", "", "Default branch name")
|
||||
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)")
|
||||
edit.Flags().BoolVar(&template, "as-template", false, "Make repository a template (use =false to disable)")
|
||||
edit.Flags().BoolVar(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)")
|
||||
group.AddCommand(edit, a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand())
|
||||
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 {
|
||||
|
||||
@@ -5,12 +5,16 @@ package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"gitea.dev/tea/internal/services"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,9 +28,29 @@ func validateRepositoryName(name string) error {
|
||||
func (a *application) repoCreateCommand() *cobra.Command {
|
||||
var opts services.RepositoryOptions
|
||||
var templateOpts services.RepositoryTemplateOptions
|
||||
var org, source, team string
|
||||
var clone 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 {
|
||||
var org, template, team, localSource, remote string
|
||||
var clone, public, push bool
|
||||
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 cmd.Flags().Changed("name") {
|
||||
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
|
||||
}
|
||||
if cmd.Flags().Changed("team") && (org == "" || strings.TrimSpace(team) == "") {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--team requires --org and a team name"))
|
||||
if err := validateCreateFlags(cmd, opts, org, team, template, localSource, clone); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("template") && source == "" {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--template requires owner/repo or a repository URL"))
|
||||
if public {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
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"))
|
||||
}
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.options.repo = template
|
||||
target, err := selected.resolve(cmd.Context(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -99,8 +102,17 @@ func (a *application) repoCreateCommand() *cobra.Command {
|
||||
}
|
||||
org = ""
|
||||
}
|
||||
service := services.Repositories{Client: target.client}
|
||||
if err := validateTemplates(cmd.Context(), service, &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -116,9 +128,8 @@ func (a *application) repoCreateCommand() *cobra.Command {
|
||||
}
|
||||
teamID = match.ID
|
||||
}
|
||||
service := services.Repositories{Client: target.client}
|
||||
var item services.Repository
|
||||
if source != "" {
|
||||
if template != "" {
|
||||
templateOpts.Name = opts.Name
|
||||
templateOpts.Owner = org
|
||||
templateOpts.Description = opts.Description
|
||||
@@ -131,40 +142,169 @@ func (a *application) repoCreateCommand() *cobra.Command {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := writeRecord(a, cmd, item, repositoryRecord); err != nil || !clone {
|
||||
return 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.StringVar(&org, "org", "", "Create under this organization")
|
||||
flags.StringVar(&team, "team", "", "Grant this organization team access (requires --org)")
|
||||
_ = 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.StringVar(&opts.Description, "description", "", "Repository description")
|
||||
flags.BoolVar(&opts.Private, "private", false, "Create a private repository")
|
||||
flags.BoolVar(&opts.AutoInit, "auto-init", false, "Initialize repository with a README")
|
||||
flags.StringVar(&opts.DefaultBranch, "default-branch", "", "Initial default branch")
|
||||
flags.StringVar(&opts.Labels, "label-template", "", "Named Gitea label set")
|
||||
flags.StringVar(&opts.Gitignores, "gitignores", "", "Comma-separated gitignore templates (requires --auto-init)")
|
||||
flags.StringVar(&opts.License, "license", "", "License template (requires --auto-init)")
|
||||
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 reusable template")
|
||||
flags.StringVar(&opts.TrustModel, "trust-model", "", "Signature trust model")
|
||||
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")
|
||||
flags.BoolVar(&templateOpts.GitHooks, "copy-git-hooks", true, "Copy template Git hooks")
|
||||
flags.BoolVar(&templateOpts.Avatar, "copy-avatar", true, "Copy template avatar")
|
||||
flags.BoolVar(&templateOpts.Labels, "copy-labels", true, "Copy template labels")
|
||||
flags.BoolVar(&templateOpts.Topics, "copy-topics", true, "Copy template topics")
|
||||
flags.BoolVar(&templateOpts.Webhooks, "copy-webhooks", false, "Copy template webhooks")
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -62,3 +62,70 @@ func TestRepositoryCreateClonesNewRepository(t *testing.T) {
|
||||
require.Equal(t, []string{call}, runner.calls)
|
||||
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")
|
||||
}
|
||||
@@ -12,17 +12,54 @@ import (
|
||||
)
|
||||
|
||||
func TestRepositoryEditPreservesExplicitClearsAndFalse(t *testing.T) {
|
||||
calls := 0
|
||||
var requests []string
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
require.Equal(t, http.MethodPatch, r.Method)
|
||||
require.Equal(t, "/api/v1/repos/alice/project", r.URL.Path)
|
||||
requirePayload(t, `{"name":"renamed","website":"","default_branch":"main","archived":false,"private":false,"template":false,"has_actions":false}`, r.Body)
|
||||
requests = append(requests, r.Method+" "+r.URL.Path)
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/repos/alice/project/branches/main":
|
||||
_, _ = 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"}`))
|
||||
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()
|
||||
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.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())
|
||||
}
|
||||
@@ -4,13 +4,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"gitea.dev/tea/internal/services"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -22,10 +26,40 @@ func validateMigrationURL(raw string) error {
|
||||
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 {
|
||||
var opts services.RepositoryMigrationOptions
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -44,62 +78,30 @@ func (a *application) repoMigrateCommand() *cobra.Command {
|
||||
}
|
||||
opts.CloneURL = serverPath
|
||||
}
|
||||
prompted := false
|
||||
for _, field := range []struct {
|
||||
value *string
|
||||
flag string
|
||||
}{{&opts.CloneURL, "clone-url"}, {&opts.Name, "name"}} {
|
||||
completed, err := a.require(cmd, field.value, field.flag)
|
||||
if err := validateMigrationFlags(cmd, opts, passwordFile, tokenFile); err != nil {
|
||||
return err
|
||||
}
|
||||
validURL := func(raw string) error { return validateMigrationURL(raw) }
|
||||
if cmd.Flags().Changed("server-path") {
|
||||
validURL = nil
|
||||
}
|
||||
prompted, err := a.requireInput(cmd, &opts.CloneURL, "clone-url", "Clone URL", validURL)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
value *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"))
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prompted {
|
||||
if err := a.confirm(cmd, "Migrate repository "+opts.Name+" to "+target.Host); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -132,16 +135,30 @@ func (a *application) repoMigrateCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
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.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.Owner, "owner", "", "Destination owner (default: current account)")
|
||||
flags.StringVar(&opts.Name, "name", "", "Destination repository name (default: the source's name)")
|
||||
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)")
|
||||
_ = command.MarkFlagDirname("server-path")
|
||||
flags.StringVar(&opts.CloneURL, "clone-url", "", "Remote repository URL")
|
||||
flags.StringVar(&opts.Service, "service", "git", "Source service: git, gitea, gitlab, gogs, github")
|
||||
flags.StringVar(&opts.Description, "description", "", "Repository description")
|
||||
_ = flags.MarkHidden("clone-url")
|
||||
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.Private, "private", false, "Make the imported repository private")
|
||||
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(&opts.AuthToken, "auth-token", "", "Source API token (prefer file input)")
|
||||
flags.StringVar(&tokenFile, "auth-token-file", "", "Read source API token from file or -")
|
||||
_ = command.RegisterFlagCompletionFunc("owner", a.completeOrgs())
|
||||
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
|
||||
}
|
||||
@@ -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", "--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", "--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", "--team", "developers"}, "--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", "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", "edit", "--name="}, "repository name cannot be empty"},
|
||||
{[]string{"repo", "edit", "--default-branch="}, "default branch cannot be empty"},
|
||||
{[]string{"repo", "edit", "--name="}, "--name 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", "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]"},
|
||||
|
||||
@@ -72,6 +72,15 @@ type RepositoryEdit struct {
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Archived *bool `json:"archived,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.
|
||||
@@ -169,14 +178,36 @@ func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOpt
|
||||
|
||||
// Edit updates only explicitly supplied resource fields.
|
||||
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 c.Repositories.EditRepo(ctx, owner, name, gitea.EditRepoOption{
|
||||
HasActions: opts.ActionsEnabled, Name: opts.Name, Website: opts.Website, DefaultBranch: opts.DefaultBranch,
|
||||
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.
|
||||
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) {
|
||||
|
||||
@@ -45,6 +45,30 @@ func (s Repositories) Generate(ctx context.Context, source string, opts Reposito
|
||||
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.
|
||||
type RepositoryMigrationOptions struct {
|
||||
Name string `json:"repo_name"`
|
||||
|
||||
Reference in new issue
Block a user