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 }