Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
51b7653
feat(discussion/client): add discussion client package
babakks Jun 1, 2026
d3a1538
feat(discussion/shared): add shared utilities for discussion commands
babakks Jun 1, 2026
8b73951
feat(discussion list): add discussion list command
babakks Jun 1, 2026
57008e7
feat(discussion view): add discussion view command
babakks Jun 1, 2026
7bd67a8
feat(discussion create): add discussion create command
babakks Jun 1, 2026
797effe
feat(discussion edit): add discussion edit command
babakks Jun 1, 2026
dc5d7c1
fix(discussion): various polish and small fixes
babakks Jun 4, 2026
0d93148
refactor(discussion): extract command-level consts for enums
babakks Jun 4, 2026
97b2296
refactor(discussion list): make pager call/error consistent with view…
babakks Jun 4, 2026
dcd3507
docs(discussion/client): add godoc to exported consts
babakks Jun 4, 2026
d6a089d
chore(discussion/client): fix formatting
babakks Jun 4, 2026
b102900
fix(discussion): handle partial failure on create/update label mutations
babakks Jun 5, 2026
6dcb0b0
docs(discussion view): improve long help text for clarity
babakks Jun 5, 2026
fbd733e
refactor(discussion): add Cursor field and ExportData to DiscussionLi…
babakks Jun 5, 2026
e104885
refactor(discussion/client): precheck discussions enabled via getRepo…
babakks Jun 5, 2026
ca8e126
fix(discussion list): print "answered" instead of checkmark in non-tt…
babakks Jun 5, 2026
ada8583
test(discussion): add acceptance tests for discussion commands
babakks Jun 5, 2026
8ec0830
docs(acceptance): add new jq2env and jq-assert functions
babakks Jun 5, 2026
f147d02
test(discussion list): consolidate tests into table-driven format
babakks Jun 5, 2026
e61df07
Merge branch 'trunk' into feature/discussion
babakks Jun 5, 2026
c1f3c1a
fix(discussion): add missing repo flag override
babakks Jun 8, 2026
9d413e7
test(discussion list): rename TestNewCmdList2 to TestNewCmdList
babakks Jun 9, 2026
e2d150d
fix(discussion): remove redundant error wrapping on ListCategories
babakks Jun 9, 2026
95fc89c
chore(discussion): remove unused HttpClient field from create and edit
babakks Jun 9, 2026
4166ecf
chore: apply formatting
babakks Jun 9, 2026
2618999
feat(discussion/client): add comment manipulation methods
babakks Jun 6, 2026
6f5e114
feat(discussion): add discussion comment command
babakks Jun 8, 2026
82ac0d7
refactor(acceptance): use discussion comment command instead of raw A…
babakks Jun 8, 2026
2629753
test(acceptance): add discussion comment acceptance test
babakks Jun 8, 2026
61a4476
test(discussion comment): add non-tty delete flag validation test case
babakks Jun 8, 2026
d026f8f
feat(discussion): support comment URLs in --replies and comment command
babakks Jun 8, 2026
6394ca8
test(acceptance): cover discussion comment URLs in comment and view t…
babakks Jun 8, 2026
bc7ed48
chore: fix formatting
babakks Jun 8, 2026
55928c9
refactor(discussion view): replace --replies flag with positional com…
babakks Jun 10, 2026
8747c69
refactor(discussion/client): take host instead of repo in GetComment
babakks Jun 10, 2026
869c044
test(acceptance): use positional comment argument in discussion view …
babakks Jun 10, 2026
8d2b059
fix(discussion view): error when --comments is used with a comment ar…
babakks Jun 10, 2026
42db02d
Merge pull request #13620 from cli/babakks/add-discussion-comment
babakks Jun 10, 2026
27aabfa
fix(discussion view): use color scheme method for success icon
babakks Jun 10, 2026
951d76e
refactor(discussion list): simplify no-results message and use succes…
babakks Jun 10, 2026
9f2da11
fix(discussion view): show comments and replies in chronological order
babakks Jun 10, 2026
06d2e34
docs(discussion list): clarify answered examples refer to Q&A discuss…
babakks Jun 10, 2026
5d77247
fix(discussion view): print only requested items in non-tty output
babakks Jun 10, 2026
d63ab8d
fix(discussion/shared): error on out-of-range discussion number in URL
babakks Jun 10, 2026
5e6a58b
test(acceptance): fix discussion comment acceptance tests
babakks Jun 10, 2026
616d929
chore(discussion/client): rename client files
babakks Jun 10, 2026
69855b7
fix(discussion comment): fix bug in requiring body/body-file in add/e…
babakks Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(discussion edit): add discussion edit command
Add `gh discussion edit` with support for:
- Non-interactive editing with --title, --body, --body-file, --category flags
- Label management with --add-label and --remove-label flags
- Interactive mode with multi-select prompts for fields to edit
- Flag presence detection (allows setting body to empty string)
- Label name to ID resolution via shared.ResolveLabels
- Body input from stdin via --body-file -

Co-authored-by: Max Beizer <max.beizer@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
  • Loading branch information
3 people committed Jun 1, 2026
commit 797effe0295914e03f4b40cb873276da1d3b7e93
272 changes: 272 additions & 0 deletions pkg/cmd/discussion/edit/edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package edit

import (
"fmt"
"net/http"
"strings"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmd/discussion/client"
"github.com/cli/cli/v2/pkg/cmd/discussion/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)

// EditOptions holds the configuration for the discussion edit command.
type EditOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
Client func() (client.DiscussionClient, error)
Prompter prompter.Prompter

Interactive bool
TitleProvided bool
BodyProvided bool
CategoryProvided bool
LabelsProvided bool

DiscussionNumber int
Title string
Body string
BodyFile string
Category string
AddLabels []string
RemoveLabels []string
}

// NewCmdEdit returns a cobra command for editing a GitHub Discussion.
func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command {
opts := &EditOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Prompter: f.Prompter,
Client: shared.DiscussionClientFunc(f),
}

cmd := &cobra.Command{
Use: "edit {<number> | <url>}",
Short: "Edit a discussion (preview)",
Long: heredoc.Doc(`
Edit a GitHub Discussion.

Without flags, the command runs interactively when connected to a terminal.
Use flags to update specific fields non-interactively.
`),
Example: heredoc.Doc(`
# Edit interactively
$ gh discussion edit 123

# Update title, body, and category
$ gh discussion edit 123 --title "Updated title" --body "Updated body" --category "Ideas"

# Update body from a file
$ gh discussion edit 123 --body-file body.md

# Add and remove labels
$ gh discussion edit 123 --add-label "bug,help wanted" --remove-label "stale"
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
number, repo, err := shared.ParseDiscussionArg(args[0])
if err != nil {
return cmdutil.FlagErrorWrap(err)
}

if repo != nil {
opts.BaseRepo = func() (ghrepo.Interface, error) {
return repo, nil
}
} else {
opts.BaseRepo = f.BaseRepo
}

opts.DiscussionNumber = number

if err := cmdutil.MutuallyExclusive("specify only one of --body or --body-file",
cmd.Flags().Changed("body"), cmd.Flags().Changed("body-file")); err != nil {
return err
}

opts.TitleProvided = cmd.Flags().Changed("title")
opts.BodyProvided = cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file")
opts.CategoryProvided = cmd.Flags().Changed("category")
opts.LabelsProvided = len(opts.AddLabels) > 0 || len(opts.RemoveLabels) > 0

noFlagsSet := !opts.TitleProvided && !opts.BodyProvided && !opts.CategoryProvided && !opts.LabelsProvided
if noFlagsSet && !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("specify at least one flag to update the discussion non-interactively")
}

opts.Interactive = noFlagsSet

if runF != nil {
return runF(opts)
}
return editRun(opts)
},
}

cmdutil.EnableRepoOverride(cmd, f)

cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "New title for the discussion")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "New body for the discussion")
cmd.Flags().StringVarP(&opts.BodyFile, "body-file", "F", "", "Read body text from file (use \"-\" to read from standard input)")
cmd.Flags().StringVarP(&opts.Category, "category", "c", "", "New category name or slug for the discussion")
cmd.Flags().StringSliceVar(&opts.AddLabels, "add-label", nil, "Add labels by `name`")
cmd.Flags().StringSliceVar(&opts.RemoveLabels, "remove-label", nil, "Remove labels by `name`")

return cmd
}

func editRun(opts *EditOptions) error {
repo, err := opts.BaseRepo()
if err != nil {
return err
}

c, err := opts.Client()
if err != nil {
return err
}

opts.IO.StartProgressIndicator()
discussion, err := c.GetByNumber(repo, opts.DiscussionNumber)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}

input := client.UpdateDiscussionInput{
DiscussionID: discussion.ID,
}

if opts.Interactive {
changed, err := promptEdit(opts, discussion, c, repo, &input)
if err != nil {
return err
}

if !changed {
return fmt.Errorf("no changes made")
Comment thread
babakks marked this conversation as resolved.
Outdated
}
} else {
if opts.TitleProvided {
if strings.TrimSpace(opts.Title) == "" {
return cmdutil.FlagErrorf("title cannot be blank")
}
input.Title = &opts.Title
}
if opts.BodyProvided {
if opts.BodyFile != "" {
bodyBytes, err := cmdutil.ReadFile(opts.BodyFile, opts.IO.In)
if err != nil {
return err
}
opts.Body = string(bodyBytes)
}
input.Body = &opts.Body
}
if opts.CategoryProvided {
opts.IO.StartProgressIndicator()
categories, err := c.ListCategories(repo)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("fetching categories: %w", err)
}
cat, err := shared.MatchCategory(opts.Category, categories)
if err != nil {
return err
}
input.CategoryID = &cat.ID
}

if opts.LabelsProvided {
opts.IO.StartProgressIndicator()
allLabels, err := c.ListLabels(repo)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("fetching labels: %w", err)
}
if len(opts.AddLabels) > 0 {
input.AddLabelIDs, err = shared.ResolveLabels(allLabels, opts.AddLabels)
if err != nil {
return err
}
}
if len(opts.RemoveLabels) > 0 {
input.RemoveLabelIDs, err = shared.ResolveLabels(allLabels, opts.RemoveLabels)
if err != nil {
return err
}
}
}
}

opts.IO.StartProgressIndicator()
updated, err := c.Update(repo, input)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}

fmt.Fprintln(opts.IO.Out, updated.URL)
return nil
}

// promptEdit runs the interactive flow, populating input with user choices. It returns a boolean indicating whether any
// changes were made, and an error if the process failed.
func promptEdit(opts *EditOptions, discussion *client.Discussion, c client.DiscussionClient, repo ghrepo.Interface, input *client.UpdateDiscussionInput) (bool, error) {
choices := []string{"Title", "Body", "Category"}
Comment thread
babakks marked this conversation as resolved.
selected, err := opts.Prompter.MultiSelect("What would you like to edit?", nil, choices)
if err != nil {
return false, err
}
if len(selected) == 0 {
return false, nil
}

for _, idx := range selected {
switch choices[idx] {
case "Title":
title, err := opts.Prompter.Input("Title", discussion.Title)
if err != nil {
return false, err
}
if strings.TrimSpace(title) == "" {
return false, fmt.Errorf("title cannot be blank")
}
input.Title = &title

case "Body":
body, err := opts.Prompter.MarkdownEditor("Body", discussion.Body, false)
if err != nil {
return false, err
}
input.Body = &body

case "Category":
opts.IO.StartProgressIndicator()
categories, err := c.ListCategories(repo)
opts.IO.StopProgressIndicator()
if err != nil {
return false, err
}
names := make([]string, len(categories))
for i, cat := range categories {
names[i] = cat.Name
}
currentName := discussion.Category.Name
idx, err := opts.Prompter.Select("Category", currentName, names)
if err != nil {
return false, err
}
input.CategoryID = &categories[idx].ID
}
}

return true, nil
}
Loading
Loading