github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/repo/edit/edit.go (about)

     1  package edit
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/AlecAivazis/survey/v2"
    14  	"github.com/MakeNowJust/heredoc"
    15  	"github.com/ungtb10d/cli/v2/api"
    16  	fd "github.com/ungtb10d/cli/v2/internal/featuredetection"
    17  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    18  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    19  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    20  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    21  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    22  	"github.com/ungtb10d/cli/v2/pkg/set"
    23  	"github.com/spf13/cobra"
    24  	"golang.org/x/sync/errgroup"
    25  )
    26  
    27  const (
    28  	allowMergeCommits = "Allow Merge Commits"
    29  	allowSquashMerge  = "Allow Squash Merging"
    30  	allowRebaseMerge  = "Allow Rebase Merging"
    31  
    32  	optionAllowForking      = "Allow Forking"
    33  	optionDefaultBranchName = "Default Branch Name"
    34  	optionDescription       = "Description"
    35  	optionHomePageURL       = "Home Page URL"
    36  	optionIssues            = "Issues"
    37  	optionMergeOptions      = "Merge Options"
    38  	optionProjects          = "Projects"
    39  	optionTemplateRepo      = "Template Repository"
    40  	optionTopics            = "Topics"
    41  	optionVisibility        = "Visibility"
    42  	optionWikis             = "Wikis"
    43  )
    44  
    45  type EditOptions struct {
    46  	HTTPClient      *http.Client
    47  	Repository      ghrepo.Interface
    48  	IO              *iostreams.IOStreams
    49  	Edits           EditRepositoryInput
    50  	AddTopics       []string
    51  	RemoveTopics    []string
    52  	InteractiveMode bool
    53  	Detector        fd.Detector
    54  	// Cache of current repo topics to avoid retrieving them
    55  	// in multiple flows.
    56  	topicsCache []string
    57  }
    58  
    59  type EditRepositoryInput struct {
    60  	AllowForking        *bool   `json:"allow_forking,omitempty"`
    61  	DefaultBranch       *string `json:"default_branch,omitempty"`
    62  	DeleteBranchOnMerge *bool   `json:"delete_branch_on_merge,omitempty"`
    63  	Description         *string `json:"description,omitempty"`
    64  	EnableAutoMerge     *bool   `json:"allow_auto_merge,omitempty"`
    65  	EnableIssues        *bool   `json:"has_issues,omitempty"`
    66  	EnableMergeCommit   *bool   `json:"allow_merge_commit,omitempty"`
    67  	EnableProjects      *bool   `json:"has_projects,omitempty"`
    68  	EnableRebaseMerge   *bool   `json:"allow_rebase_merge,omitempty"`
    69  	EnableSquashMerge   *bool   `json:"allow_squash_merge,omitempty"`
    70  	EnableWiki          *bool   `json:"has_wiki,omitempty"`
    71  	Homepage            *string `json:"homepage,omitempty"`
    72  	IsTemplate          *bool   `json:"is_template,omitempty"`
    73  	Visibility          *string `json:"visibility,omitempty"`
    74  }
    75  
    76  func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobra.Command {
    77  	opts := &EditOptions{
    78  		IO: f.IOStreams,
    79  	}
    80  
    81  	cmd := &cobra.Command{
    82  		Use:   "edit [<repository>]",
    83  		Short: "Edit repository settings",
    84  		Annotations: map[string]string{
    85  			"help:arguments": heredoc.Doc(`
    86  				A repository can be supplied as an argument in any of the following formats:
    87  				- "OWNER/REPO"
    88  				- by URL, e.g. "https://github.com/OWNER/REPO"
    89  			`),
    90  		},
    91  		Long: heredoc.Docf(`
    92  			Edit repository settings.
    93  
    94  			To toggle a setting off, use the %[1]s--flag=false%[1]s syntax.
    95  		`, "`"),
    96  		Args: cobra.MaximumNArgs(1),
    97  		Example: heredoc.Doc(`
    98  			# enable issues and wiki
    99  			gh repo edit --enable-issues --enable-wiki
   100  
   101  			# disable projects
   102  			gh repo edit --enable-projects=false
   103  		`),
   104  		RunE: func(cmd *cobra.Command, args []string) error {
   105  			if len(args) > 0 {
   106  				var err error
   107  				opts.Repository, err = ghrepo.FromFullName(args[0])
   108  				if err != nil {
   109  					return err
   110  				}
   111  			} else {
   112  				var err error
   113  				opts.Repository, err = f.BaseRepo()
   114  				if err != nil {
   115  					return err
   116  				}
   117  			}
   118  
   119  			if httpClient, err := f.HttpClient(); err == nil {
   120  				opts.HTTPClient = httpClient
   121  			} else {
   122  				return err
   123  			}
   124  
   125  			if cmd.Flags().NFlag() == 0 {
   126  				opts.InteractiveMode = true
   127  			}
   128  
   129  			if opts.InteractiveMode && !opts.IO.CanPrompt() {
   130  				return cmdutil.FlagErrorf("specify properties to edit when not running interactively")
   131  			}
   132  
   133  			if runF != nil {
   134  				return runF(opts)
   135  			}
   136  			return editRun(cmd.Context(), opts)
   137  		},
   138  	}
   139  
   140  	cmdutil.NilStringFlag(cmd, &opts.Edits.Description, "description", "d", "Description of the repository")
   141  	cmdutil.NilStringFlag(cmd, &opts.Edits.Homepage, "homepage", "h", "Repository home page `URL`")
   142  	cmdutil.NilStringFlag(cmd, &opts.Edits.DefaultBranch, "default-branch", "", "Set the default branch `name` for the repository")
   143  	cmdutil.NilStringFlag(cmd, &opts.Edits.Visibility, "visibility", "", "Change the visibility of the repository to {public,private,internal}")
   144  	cmdutil.NilBoolFlag(cmd, &opts.Edits.IsTemplate, "template", "", "Make the repository available as a template repository")
   145  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableIssues, "enable-issues", "", "Enable issues in the repository")
   146  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableProjects, "enable-projects", "", "Enable projects in the repository")
   147  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableWiki, "enable-wiki", "", "Enable wiki in the repository")
   148  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableMergeCommit, "enable-merge-commit", "", "Enable merging pull requests via merge commit")
   149  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableSquashMerge, "enable-squash-merge", "", "Enable merging pull requests via squashed commit")
   150  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableRebaseMerge, "enable-rebase-merge", "", "Enable merging pull requests via rebase")
   151  	cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableAutoMerge, "enable-auto-merge", "", "Enable auto-merge functionality")
   152  	cmdutil.NilBoolFlag(cmd, &opts.Edits.DeleteBranchOnMerge, "delete-branch-on-merge", "", "Delete head branch when pull requests are merged")
   153  	cmdutil.NilBoolFlag(cmd, &opts.Edits.AllowForking, "allow-forking", "", "Allow forking of an organization repository")
   154  	cmd.Flags().StringSliceVar(&opts.AddTopics, "add-topic", nil, "Add repository topic")
   155  	cmd.Flags().StringSliceVar(&opts.RemoveTopics, "remove-topic", nil, "Remove repository topic")
   156  
   157  	return cmd
   158  }
   159  
   160  func editRun(ctx context.Context, opts *EditOptions) error {
   161  	repo := opts.Repository
   162  
   163  	if opts.InteractiveMode {
   164  		detector := opts.Detector
   165  		if detector == nil {
   166  			cachedClient := api.NewCachedHTTPClient(opts.HTTPClient, time.Hour*24)
   167  			detector = fd.NewDetector(cachedClient, repo.RepoHost())
   168  		}
   169  		repoFeatures, err := detector.RepositoryFeatures()
   170  		if err != nil {
   171  			return err
   172  		}
   173  
   174  		apiClient := api.NewClientFromHTTP(opts.HTTPClient)
   175  		fieldsToRetrieve := []string{
   176  			"defaultBranchRef",
   177  			"deleteBranchOnMerge",
   178  			"description",
   179  			"hasIssuesEnabled",
   180  			"hasProjectsEnabled",
   181  			"hasWikiEnabled",
   182  			"homepageUrl",
   183  			"isInOrganization",
   184  			"isTemplate",
   185  			"mergeCommitAllowed",
   186  			"rebaseMergeAllowed",
   187  			"repositoryTopics",
   188  			"squashMergeAllowed",
   189  		}
   190  		if repoFeatures.VisibilityField {
   191  			fieldsToRetrieve = append(fieldsToRetrieve, "visibility")
   192  		}
   193  		if repoFeatures.AutoMerge {
   194  			fieldsToRetrieve = append(fieldsToRetrieve, "autoMergeAllowed")
   195  		}
   196  
   197  		opts.IO.StartProgressIndicator()
   198  		fetchedRepo, err := api.FetchRepository(apiClient, opts.Repository, fieldsToRetrieve)
   199  		opts.IO.StopProgressIndicator()
   200  		if err != nil {
   201  			return err
   202  		}
   203  		err = interactiveRepoEdit(opts, fetchedRepo)
   204  		if err != nil {
   205  			return err
   206  		}
   207  	}
   208  
   209  	apiPath := fmt.Sprintf("repos/%s/%s", repo.RepoOwner(), repo.RepoName())
   210  
   211  	body := &bytes.Buffer{}
   212  	enc := json.NewEncoder(body)
   213  	if err := enc.Encode(opts.Edits); err != nil {
   214  		return err
   215  	}
   216  
   217  	g := errgroup.Group{}
   218  
   219  	if body.Len() > 3 {
   220  		g.Go(func() error {
   221  			apiClient := api.NewClientFromHTTP(opts.HTTPClient)
   222  			_, err := api.CreateRepoTransformToV4(apiClient, repo.RepoHost(), "PATCH", apiPath, body)
   223  			return err
   224  		})
   225  	}
   226  
   227  	if len(opts.AddTopics) > 0 || len(opts.RemoveTopics) > 0 {
   228  		g.Go(func() error {
   229  			// opts.topicsCache gets populated in interactive mode
   230  			if !opts.InteractiveMode {
   231  				var err error
   232  				opts.topicsCache, err = getTopics(ctx, opts.HTTPClient, repo)
   233  				if err != nil {
   234  					return err
   235  				}
   236  			}
   237  			oldTopics := set.NewStringSet()
   238  			oldTopics.AddValues(opts.topicsCache)
   239  
   240  			newTopics := set.NewStringSet()
   241  			newTopics.AddValues(opts.topicsCache)
   242  			newTopics.AddValues(opts.AddTopics)
   243  			newTopics.RemoveValues(opts.RemoveTopics)
   244  
   245  			if oldTopics.Equal(newTopics) {
   246  				return nil
   247  			}
   248  			return setTopics(ctx, opts.HTTPClient, repo, newTopics.ToSlice())
   249  		})
   250  	}
   251  
   252  	err := g.Wait()
   253  	if err != nil {
   254  		return err
   255  	}
   256  
   257  	if opts.IO.IsStdoutTTY() {
   258  		cs := opts.IO.ColorScheme()
   259  		fmt.Fprintf(opts.IO.Out,
   260  			"%s Edited repository %s\n",
   261  			cs.SuccessIcon(),
   262  			ghrepo.FullName(repo))
   263  	}
   264  
   265  	return nil
   266  }
   267  
   268  func interactiveChoice(r *api.Repository) ([]string, error) {
   269  	options := []string{
   270  		optionDefaultBranchName,
   271  		optionDescription,
   272  		optionHomePageURL,
   273  		optionIssues,
   274  		optionMergeOptions,
   275  		optionProjects,
   276  		optionTemplateRepo,
   277  		optionTopics,
   278  		optionVisibility,
   279  		optionWikis,
   280  	}
   281  	if r.IsInOrganization {
   282  		options = append(options, optionAllowForking)
   283  	}
   284  	var answers []string
   285  	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   286  	err := prompt.SurveyAskOne(&survey.MultiSelect{
   287  		Message: "What do you want to edit?",
   288  		Options: options,
   289  	}, &answers, survey.WithPageSize(11))
   290  	return answers, err
   291  }
   292  
   293  func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error {
   294  	for _, v := range r.RepositoryTopics.Nodes {
   295  		opts.topicsCache = append(opts.topicsCache, v.Topic.Name)
   296  	}
   297  	choices, err := interactiveChoice(r)
   298  	if err != nil {
   299  		return err
   300  	}
   301  	for _, c := range choices {
   302  		switch c {
   303  		case optionDescription:
   304  			opts.Edits.Description = &r.Description
   305  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   306  			err = prompt.SurveyAskOne(&survey.Input{
   307  				Message: "Description of the repository",
   308  				Default: r.Description,
   309  			}, opts.Edits.Description)
   310  			if err != nil {
   311  				return err
   312  			}
   313  		case optionHomePageURL:
   314  			opts.Edits.Homepage = &r.HomepageURL
   315  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   316  			err = prompt.SurveyAskOne(&survey.Input{
   317  				Message: "Repository home page URL",
   318  				Default: r.HomepageURL,
   319  			}, opts.Edits.Homepage)
   320  			if err != nil {
   321  				return err
   322  			}
   323  		case optionTopics:
   324  			var addTopics string
   325  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   326  			err = prompt.SurveyAskOne(&survey.Input{
   327  				Message: "Add topics?(csv format)",
   328  			}, &addTopics)
   329  			if err != nil {
   330  				return err
   331  			}
   332  			if len(strings.TrimSpace(addTopics)) > 0 {
   333  				opts.AddTopics = parseTopics(addTopics)
   334  			}
   335  
   336  			if len(opts.topicsCache) > 0 {
   337  				//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   338  				err = prompt.SurveyAskOne(&survey.MultiSelect{
   339  					Message: "Remove Topics",
   340  					Options: opts.topicsCache,
   341  				}, &opts.RemoveTopics)
   342  				if err != nil {
   343  					return err
   344  				}
   345  			}
   346  		case optionDefaultBranchName:
   347  			opts.Edits.DefaultBranch = &r.DefaultBranchRef.Name
   348  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   349  			err = prompt.SurveyAskOne(&survey.Input{
   350  				Message: "Default branch name",
   351  				Default: r.DefaultBranchRef.Name,
   352  			}, opts.Edits.DefaultBranch)
   353  			if err != nil {
   354  				return err
   355  			}
   356  		case optionWikis:
   357  			opts.Edits.EnableWiki = &r.HasWikiEnabled
   358  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   359  			err = prompt.SurveyAskOne(&survey.Confirm{
   360  				Message: "Enable Wikis?",
   361  				Default: r.HasWikiEnabled,
   362  			}, opts.Edits.EnableWiki)
   363  			if err != nil {
   364  				return err
   365  			}
   366  		case optionIssues:
   367  			opts.Edits.EnableIssues = &r.HasIssuesEnabled
   368  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   369  			err = prompt.SurveyAskOne(&survey.Confirm{
   370  				Message: "Enable Issues?",
   371  				Default: r.HasIssuesEnabled,
   372  			}, opts.Edits.EnableIssues)
   373  			if err != nil {
   374  				return err
   375  			}
   376  		case optionProjects:
   377  			opts.Edits.EnableProjects = &r.HasProjectsEnabled
   378  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   379  			err = prompt.SurveyAskOne(&survey.Confirm{
   380  				Message: "Enable Projects?",
   381  				Default: r.HasProjectsEnabled,
   382  			}, opts.Edits.EnableProjects)
   383  			if err != nil {
   384  				return err
   385  			}
   386  		case optionVisibility:
   387  			opts.Edits.Visibility = &r.Visibility
   388  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   389  			err = prompt.SurveyAskOne(&survey.Select{
   390  				Message: "Visibility",
   391  				Options: []string{"public", "private", "internal"},
   392  				Default: strings.ToLower(r.Visibility),
   393  			}, opts.Edits.Visibility)
   394  			if err != nil {
   395  				return err
   396  			}
   397  		case optionMergeOptions:
   398  			var defaultMergeOptions []string
   399  			var selectedMergeOptions []string
   400  			if r.MergeCommitAllowed {
   401  				defaultMergeOptions = append(defaultMergeOptions, allowMergeCommits)
   402  			}
   403  			if r.SquashMergeAllowed {
   404  				defaultMergeOptions = append(defaultMergeOptions, allowSquashMerge)
   405  			}
   406  			if r.RebaseMergeAllowed {
   407  				defaultMergeOptions = append(defaultMergeOptions, allowRebaseMerge)
   408  			}
   409  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   410  			err = prompt.SurveyAskOne(&survey.MultiSelect{
   411  				Message: "Allowed merge strategies",
   412  				Default: defaultMergeOptions,
   413  				Options: []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge},
   414  			}, &selectedMergeOptions)
   415  			if err != nil {
   416  				return err
   417  			}
   418  			enableMergeCommit := isIncluded(allowMergeCommits, selectedMergeOptions)
   419  			opts.Edits.EnableMergeCommit = &enableMergeCommit
   420  			enableSquashMerge := isIncluded(allowSquashMerge, selectedMergeOptions)
   421  			opts.Edits.EnableSquashMerge = &enableSquashMerge
   422  			enableRebaseMerge := isIncluded(allowRebaseMerge, selectedMergeOptions)
   423  			opts.Edits.EnableRebaseMerge = &enableRebaseMerge
   424  			if !enableMergeCommit && !enableSquashMerge && !enableRebaseMerge {
   425  				return fmt.Errorf("you need to allow at least one merge strategy")
   426  			}
   427  
   428  			opts.Edits.EnableAutoMerge = &r.AutoMergeAllowed
   429  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   430  			err = prompt.SurveyAskOne(&survey.Confirm{
   431  				Message: "Enable Auto Merge?",
   432  				Default: r.AutoMergeAllowed,
   433  			}, opts.Edits.EnableAutoMerge)
   434  			if err != nil {
   435  				return err
   436  			}
   437  
   438  			opts.Edits.DeleteBranchOnMerge = &r.DeleteBranchOnMerge
   439  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   440  			err = prompt.SurveyAskOne(&survey.Confirm{
   441  				Message: "Automatically delete head branches after merging?",
   442  				Default: r.DeleteBranchOnMerge,
   443  			}, opts.Edits.DeleteBranchOnMerge)
   444  			if err != nil {
   445  				return err
   446  			}
   447  		case optionTemplateRepo:
   448  			opts.Edits.IsTemplate = &r.IsTemplate
   449  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   450  			err = prompt.SurveyAskOne(&survey.Confirm{
   451  				Message: "Convert into a template repository?",
   452  				Default: r.IsTemplate,
   453  			}, opts.Edits.IsTemplate)
   454  			if err != nil {
   455  				return err
   456  			}
   457  		case optionAllowForking:
   458  			opts.Edits.AllowForking = &r.ForkingAllowed
   459  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   460  			err = prompt.SurveyAskOne(&survey.Confirm{
   461  				Message: "Allow forking (of an organization repository)?",
   462  				Default: r.ForkingAllowed,
   463  			}, opts.Edits.AllowForking)
   464  			if err != nil {
   465  				return err
   466  			}
   467  		}
   468  	}
   469  	return nil
   470  }
   471  
   472  func parseTopics(s string) []string {
   473  	topics := strings.Split(s, ",")
   474  	for i, topic := range topics {
   475  		topics[i] = strings.TrimSpace(topic)
   476  	}
   477  	return topics
   478  }
   479  
   480  func getTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface) ([]string, error) {
   481  	apiPath := fmt.Sprintf("repos/%s/%s/topics", repo.RepoOwner(), repo.RepoName())
   482  	req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+apiPath, nil)
   483  	if err != nil {
   484  		return nil, err
   485  	}
   486  
   487  	// "mercy-preview" is still needed for some GitHub Enterprise versions
   488  	req.Header.Set("Accept", "application/vnd.github.mercy-preview+json")
   489  	res, err := httpClient.Do(req)
   490  	if err != nil {
   491  		return nil, err
   492  	}
   493  	if res.StatusCode != http.StatusOK {
   494  		return nil, api.HandleHTTPError(res)
   495  	}
   496  
   497  	var responseData struct {
   498  		Names []string `json:"names"`
   499  	}
   500  	dec := json.NewDecoder(res.Body)
   501  	err = dec.Decode(&responseData)
   502  	return responseData.Names, err
   503  }
   504  
   505  func setTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, topics []string) error {
   506  	payload := struct {
   507  		Names []string `json:"names"`
   508  	}{
   509  		Names: topics,
   510  	}
   511  	body := &bytes.Buffer{}
   512  	dec := json.NewEncoder(body)
   513  	if err := dec.Encode(&payload); err != nil {
   514  		return err
   515  	}
   516  
   517  	apiPath := fmt.Sprintf("repos/%s/%s/topics", repo.RepoOwner(), repo.RepoName())
   518  	req, err := http.NewRequestWithContext(ctx, "PUT", ghinstance.RESTPrefix(repo.RepoHost())+apiPath, body)
   519  	if err != nil {
   520  		return err
   521  	}
   522  
   523  	req.Header.Set("Content-type", "application/json")
   524  	// "mercy-preview" is still needed for some GitHub Enterprise versions
   525  	req.Header.Set("Accept", "application/vnd.github.mercy-preview+json")
   526  	res, err := httpClient.Do(req)
   527  	if err != nil {
   528  		return err
   529  	}
   530  
   531  	if res.StatusCode != http.StatusOK {
   532  		return api.HandleHTTPError(res)
   533  	}
   534  
   535  	if res.Body != nil {
   536  		_, _ = io.Copy(io.Discard, res.Body)
   537  	}
   538  
   539  	return nil
   540  }
   541  
   542  func isIncluded(value string, opts []string) bool {
   543  	for _, opt := range opts {
   544  		if strings.EqualFold(opt, value) {
   545  			return true
   546  		}
   547  	}
   548  	return false
   549  }