From 44e9f4bd50647aba6faa593ea65bc5a0dfe66bc8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:42:40 -0600 Subject: [PATCH] feat: add --type, --parent, --blocked-by, --blocking to issue create Post-creation mutations for Issues 2.0 fields: - --type: resolve type name to ID via RepoIssueTypes, then updateIssueIssueType mutation - --parent: resolve issue ref (number or URL), then addSubIssue mutation (supports cross-repo URLs) - --blocked-by: resolve refs, then addBlockedBy mutations - --blocking: resolve refs, then addBlockedBy with swapped args Interactive mode: type picker when repo has issue types configured. GHES: relationships gated behind IssueRelationshipsSupported feature detection (3.19+). Types and sub-issues need no detection (GA 3.17+). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/create/create.go | 138 +++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 8e6c6255f..a8b4f5074 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/MakeNowJust/heredoc" @@ -14,6 +15,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" + issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -47,6 +49,11 @@ type CreateOptions struct { Projects []string Milestone string Template string + + IssueType string + Parent string + BlockedBy []string + Blocking []string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -84,6 +91,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co $ gh issue create --assignee "@copilot" $ gh issue create --project "Roadmap" $ gh issue create --template "Bug Report" + $ gh issue create --type Bug + $ gh issue create --parent 100 + $ gh issue create --parent https://github.com/cli/go-gh/issues/42 + $ gh issue create --blocked-by 200,201 --blocking 300 `), Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, @@ -141,6 +152,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") cmd.Flags().StringVarP(&opts.Template, "template", "T", "", "Template `name` to use as starting body text") + cmd.Flags().StringVar(&opts.IssueType, "type", "", "Set the issue type by `name`") + cmd.Flags().StringVar(&opts.Parent, "parent", "", "Add the new issue as a sub-issue of the specified parent `number` or URL") + cmd.Flags().StringSliceVar(&opts.BlockedBy, "blocked-by", nil, "Mark the new issue as blocked by these issue `numbers` or URLs") + cmd.Flags().StringSliceVar(&opts.Blocking, "blocking", nil, "Mark the new issue as blocking these issue `numbers` or URLs") return cmd } @@ -289,6 +304,23 @@ func createRun(opts *CreateOptions) (err error) { } } + // Interactive issue type selection + if opts.IssueType == "" { + issueTypes, typesErr := api.RepoIssueTypes(apiClient, baseRepo) + if typesErr == nil && len(issueTypes) > 0 { + typeNames := make([]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + } + var selected int + selected, err = opts.Prompter.Select("Issue type", "", typeNames) + if err != nil { + return + } + opts.IssueType = typeNames[selected] + } + } + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return @@ -379,6 +411,28 @@ func createRun(opts *CreateOptions) (err error) { return } + // Post-creation mutations for Issues 2.0 fields + err = applyIssueTypes(apiClient, baseRepo, newIssue, opts) + if err != nil { + return + } + + err = applyParent(apiClient, baseRepo, newIssue, opts) + if err != nil { + return + } + + // TODO IssueRelationshipsCleanup + if issueFeatures.IssueRelationshipsSupported { + err = applyRelationships(apiClient, baseRepo, newIssue, opts) + if err != nil { + return + } + } else if len(opts.BlockedBy) > 0 || len(opts.Blocking) > 0 { + err = fmt.Errorf("issue relationships are not supported on this GitHub Enterprise Server version") + return + } + fmt.Fprintln(opts.IO.Out, newIssue.URL) } else { panic("Unreachable state") @@ -391,3 +445,87 @@ func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prS openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } + +// applyIssueTypes resolves the --type flag and sets the issue type via mutation. +func applyIssueTypes(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, opts *CreateOptions) error { + if opts.IssueType == "" { + return nil + } + + issueTypes, err := api.RepoIssueTypes(client, baseRepo) + if err != nil { + return err + } + + issueTypeID := "" + typeNames := make([]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + if strings.EqualFold(t.Name, opts.IssueType) { + issueTypeID = t.ID + } + } + + if issueTypeID == "" { + return fmt.Errorf("type %q not found; available types: %s", opts.IssueType, strings.Join(typeNames, ", ")) + } + + return api.UpdateIssueIssueType(client, baseRepo.RepoHost(), issue.ID, issueTypeID) +} + +// applyParent resolves the --parent flag and adds the issue as a sub-issue. +func applyParent(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, opts *CreateOptions) error { + if opts.Parent == "" { + return nil + } + + parentID, err := resolveIssueRef(client, baseRepo, opts.Parent) + if err != nil { + return fmt.Errorf("resolving parent: %w", err) + } + + return api.AddSubIssue(client, baseRepo.RepoHost(), parentID, issue.ID, false) +} + +// applyRelationships resolves the --blocked-by and --blocking flags and creates relationships. +func applyRelationships(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, opts *CreateOptions) error { + hostname := baseRepo.RepoHost() + + for _, ref := range opts.BlockedBy { + blockingID, err := resolveIssueRef(client, baseRepo, ref) + if err != nil { + return fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err) + } + if err := api.AddBlockedBy(client, hostname, issue.ID, blockingID); err != nil { + return err + } + } + + for _, ref := range opts.Blocking { + // --blocking swaps the args: the OTHER issue is blocked by THIS issue + blockedID, err := resolveIssueRef(client, baseRepo, ref) + if err != nil { + return fmt.Errorf("resolving --blocking reference %q: %w", ref, err) + } + if err := api.AddBlockedBy(client, hostname, blockedID, issue.ID); err != nil { + return err + } + } + + return nil +} + +// resolveIssueRef parses an issue reference (number or URL) and returns its node ID. +func resolveIssueRef(client *api.Client, baseRepo ghrepo.Interface, ref string) (string, error) { + number, repo, err := issueShared.ParseIssueFromArg(ref) + if err != nil { + return "", err + } + + targetRepo := baseRepo + if r, ok := repo.Value(); ok { + targetRepo = r + } + + return api.IssueNodeID(client, targetRepo, number) +}