Fix two issues in the discussion view command: 1. GraphQL injection via cursor interpolation: The --after cursor value was interpolated directly into the raw GraphQL query string using fmt.Sprintf, which is unsafe since cursor values come from user input. Now uses GraphQL variables ($cursor: String) instead, matching the pattern used by issue list, pr list, and other commands. 2. Incomplete --json comments output: Running `gh discussion view N --json comments` silently returned only totalCount with no comment nodes, because the data fetch was gated solely on the --comments flag. Now checks if the JSON exporter requests the comments field and fetches full comment data accordingly, matching how issue view and pr view drive data loading from exporter fields. Also fixes example text that said "newest" but showed --order oldest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
461 lines
12 KiB
Go
461 lines
12 KiB
Go
package view
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/internal/browser"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/text"
|
|
"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/cli/cli/v2/pkg/markdown"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var discussionFields = []string{
|
|
"id",
|
|
"number",
|
|
"title",
|
|
"body",
|
|
"url",
|
|
"closed",
|
|
"state",
|
|
"stateReason",
|
|
"author",
|
|
"category",
|
|
"labels",
|
|
"answered",
|
|
"answerChosenAt",
|
|
"answerChosenBy",
|
|
"comments",
|
|
"reactionGroups",
|
|
"createdAt",
|
|
"updatedAt",
|
|
"closedAt",
|
|
"locked",
|
|
}
|
|
|
|
var reactionEmoji = map[string]string{
|
|
"THUMBS_UP": "\U0001f44d",
|
|
"THUMBS_DOWN": "\U0001f44e",
|
|
"LAUGH": "\U0001f604",
|
|
"HOORAY": "\U0001f389",
|
|
"CONFUSED": "\U0001f615",
|
|
"HEART": "\u2764\ufe0f",
|
|
"ROCKET": "\U0001f680",
|
|
"EYES": "\U0001f440",
|
|
}
|
|
|
|
func reactionGroupList(groups []client.ReactionGroup) string {
|
|
var parts []string
|
|
for _, g := range groups {
|
|
if g.TotalCount == 0 {
|
|
continue
|
|
}
|
|
emoji := reactionEmoji[g.Content]
|
|
if emoji == "" {
|
|
emoji = g.Content
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s %d", emoji, g.TotalCount))
|
|
}
|
|
return strings.Join(parts, " • ")
|
|
}
|
|
|
|
// ViewOptions holds the configuration for the view command.
|
|
type ViewOptions struct {
|
|
IO *iostreams.IOStreams
|
|
BaseRepo func() (ghrepo.Interface, error)
|
|
Browser browser.Browser
|
|
Client func() (client.DiscussionClient, error)
|
|
|
|
DiscussionNumber int
|
|
WebMode bool
|
|
Comments bool
|
|
Limit int
|
|
After string
|
|
Order string
|
|
Exporter cmdutil.Exporter
|
|
Now func() time.Time
|
|
}
|
|
|
|
// NewCmdView creates the "discussion view" command.
|
|
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
|
|
opts := &ViewOptions{
|
|
IO: f.IOStreams,
|
|
Browser: f.Browser,
|
|
Now: time.Now,
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "view {<number> | <url>}",
|
|
Short: "View a discussion (preview)",
|
|
Long: heredoc.Docf(`
|
|
Display the title, body, and other information about a discussion.
|
|
|
|
With %[1]s--comments%[1]s flag, show threaded comments on the discussion.
|
|
Use %[1]s--order%[1]s to control comment ordering (oldest or newest first).
|
|
Use %[1]s--limit%[1]s and %[1]s--after%[1]s for paginating through comments.
|
|
|
|
With %[1]s--web%[1]s flag, open the discussion in a web browser instead.
|
|
`, "`"),
|
|
Example: heredoc.Doc(`
|
|
# View a discussion by number
|
|
$ gh discussion view 123
|
|
|
|
# View a discussion by URL
|
|
$ gh discussion view https://github.com/OWNER/REPO/discussions/123
|
|
|
|
# View with comments
|
|
$ gh discussion view 123 --comments
|
|
|
|
# View with newest comments first
|
|
$ gh discussion view 123 --comments --order newest
|
|
|
|
# Limit to 10 comments
|
|
$ gh discussion view 123 --comments --limit 10
|
|
|
|
# Fetch the next page of comments
|
|
$ gh discussion view 123 --comments --after CURSOR
|
|
|
|
# Open in browser
|
|
$ gh discussion view 123 --web
|
|
`),
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
commentsMode := opts.Comments || (opts.Exporter != nil && exporterNeedsComments(opts.Exporter))
|
|
if cmd.Flags().Changed("order") && !commentsMode {
|
|
return cmdutil.FlagErrorf("--order requires --comments")
|
|
}
|
|
if cmd.Flags().Changed("limit") && !commentsMode {
|
|
return cmdutil.FlagErrorf("--limit requires --comments")
|
|
}
|
|
if cmd.Flags().Changed("after") && !commentsMode {
|
|
return cmdutil.FlagErrorf("--after requires --comments")
|
|
}
|
|
if opts.Limit < 1 {
|
|
return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit)
|
|
}
|
|
|
|
number, repo, err := shared.ParseDiscussionArg(args[0])
|
|
if err != nil {
|
|
return cmdutil.FlagErrorf("%s", err)
|
|
}
|
|
|
|
if repo != nil {
|
|
opts.BaseRepo = func() (ghrepo.Interface, error) {
|
|
return repo, nil
|
|
}
|
|
} else {
|
|
opts.BaseRepo = f.BaseRepo
|
|
}
|
|
|
|
opts.DiscussionNumber = number
|
|
opts.Client = shared.DiscussionClientFunc(f)
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return viewRun(opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open a discussion in the browser")
|
|
cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View discussion comments")
|
|
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of comments to fetch")
|
|
cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page of comments")
|
|
cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "newest", []string{"oldest", "newest"}, "Order of comments")
|
|
cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionFields)
|
|
|
|
return cmd
|
|
}
|
|
|
|
// exporterNeedsComments returns true when the JSON exporter requests the comments field.
|
|
func exporterNeedsComments(exporter cmdutil.Exporter) bool {
|
|
for _, f := range exporter.Fields() {
|
|
if f == "comments" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// needsComments returns true when the command should fetch full comment data,
|
|
// either because --comments was set or because --json requested the comments field.
|
|
func needsComments(opts *ViewOptions) bool {
|
|
if opts.Comments {
|
|
return true
|
|
}
|
|
if opts.Exporter != nil {
|
|
return exporterNeedsComments(opts.Exporter)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func viewRun(opts *ViewOptions) error {
|
|
repo, err := opts.BaseRepo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.WebMode {
|
|
openURL := ghrepo.GenerateRepoURL(repo, "discussions/%d", opts.DiscussionNumber)
|
|
if opts.IO.IsStdoutTTY() {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
|
|
}
|
|
return opts.Browser.Browse(openURL)
|
|
}
|
|
|
|
c, err := opts.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts.IO.DetectTerminalTheme()
|
|
opts.IO.StartProgressIndicator()
|
|
|
|
var discussion *client.Discussion
|
|
if needsComments(opts) {
|
|
discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, opts.Limit, opts.After, opts.Order == "newest")
|
|
} else {
|
|
discussion, err = c.GetByNumber(repo, opts.DiscussionNumber)
|
|
}
|
|
|
|
opts.IO.StopProgressIndicator()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.Exporter != nil {
|
|
return opts.Exporter.Write(opts.IO, discussion)
|
|
}
|
|
|
|
if err := opts.IO.StartPager(); err != nil {
|
|
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
|
|
}
|
|
defer opts.IO.StopPager()
|
|
|
|
if opts.IO.IsStdoutTTY() {
|
|
return printHumanView(opts, discussion)
|
|
}
|
|
|
|
return printRawView(opts.IO.Out, discussion, opts.Comments)
|
|
}
|
|
|
|
func printHumanView(opts *ViewOptions, d *client.Discussion) error {
|
|
out := opts.IO.Out
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
numberStr := fmt.Sprintf("#%d", d.Number)
|
|
if !d.Closed {
|
|
numberStr = cs.Green(numberStr)
|
|
} else {
|
|
numberStr = cs.Muted(numberStr)
|
|
}
|
|
fmt.Fprintf(out, "%s %s\n", cs.Bold(d.Title), numberStr)
|
|
|
|
state := "Open"
|
|
stateColor := cs.Green
|
|
if d.Closed {
|
|
state = "Closed"
|
|
stateColor = cs.Muted
|
|
}
|
|
|
|
verb := "Started by"
|
|
if d.Category.IsAnswerable {
|
|
verb = "Asked by"
|
|
}
|
|
|
|
fmt.Fprintf(out, "%s · %s · %s %s · %s · %s\n",
|
|
stateColor(state),
|
|
d.Category.Name,
|
|
verb,
|
|
d.Author.Login,
|
|
text.FuzzyAgo(opts.Now(), d.CreatedAt),
|
|
text.Pluralize(d.Comments.TotalCount, "comment"),
|
|
)
|
|
|
|
if labels := labelList(d.Labels, cs); labels != "" {
|
|
fmt.Fprint(out, cs.Bold("Labels: "))
|
|
fmt.Fprintln(out, labels)
|
|
}
|
|
|
|
var md string
|
|
if d.Body == "" {
|
|
md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided"))
|
|
} else {
|
|
var err error
|
|
md, err = markdown.Render(d.Body,
|
|
markdown.WithTheme(opts.IO.TerminalTheme()),
|
|
markdown.WithWrap(opts.IO.TerminalWidth()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Fprintf(out, "\n%s\n", md)
|
|
|
|
if reactions := reactionGroupList(d.ReactionGroups); reactions != "" {
|
|
fmt.Fprintln(out, reactions)
|
|
fmt.Fprintln(out)
|
|
}
|
|
|
|
// Comments section
|
|
if opts.Comments && d.Comments.TotalCount > 0 {
|
|
fmt.Fprintln(out, cs.Bold("Comments"))
|
|
fmt.Fprintln(out)
|
|
|
|
for _, c := range d.Comments.Comments {
|
|
if err := printHumanComment(opts, out, c, ""); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if shown := len(d.Comments.Comments); shown < d.Comments.TotalCount {
|
|
remaining := d.Comments.TotalCount - shown
|
|
age := "more"
|
|
if d.Comments.Direction == client.DiscussionCommentListDirectionForward {
|
|
age = "newer"
|
|
} else if d.Comments.Direction == client.DiscussionCommentListDirectionBackward {
|
|
age = "older"
|
|
}
|
|
fmt.Fprintf(out, cs.Muted(" And %d %s comments\n"), remaining, age)
|
|
fmt.Fprintln(out)
|
|
}
|
|
|
|
if d.Comments.NextCursor != "" {
|
|
fmt.Fprintf(out, cs.Muted("To see more comments, pass: --after %s\n"), d.Comments.NextCursor)
|
|
fmt.Fprintln(out)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(out, cs.Muted("View this discussion on GitHub: %s\n"), d.URL)
|
|
|
|
return nil
|
|
}
|
|
|
|
func printRawView(out io.Writer, d *client.Discussion, showComments bool) error {
|
|
fmt.Fprintf(out, "title:\t%s\n", d.Title)
|
|
state := "OPEN"
|
|
if d.Closed {
|
|
state = "CLOSED"
|
|
}
|
|
fmt.Fprintf(out, "state:\t%s\n", state)
|
|
fmt.Fprintf(out, "category:\t%s\n", d.Category.Name)
|
|
fmt.Fprintf(out, "author:\t%s\n", d.Author.Login)
|
|
fmt.Fprintf(out, "labels:\t%s\n", labelList(d.Labels, nil))
|
|
fmt.Fprintf(out, "comments:\t%d\n", d.Comments.TotalCount)
|
|
if showComments && d.Comments.NextCursor != "" {
|
|
fmt.Fprintf(out, "next:\t%s\n", d.Comments.NextCursor)
|
|
}
|
|
fmt.Fprintf(out, "number:\t%d\n", d.Number)
|
|
fmt.Fprintf(out, "url:\t%s\n", d.URL)
|
|
fmt.Fprintln(out, "--")
|
|
fmt.Fprintln(out, d.Body)
|
|
|
|
if showComments {
|
|
for _, c := range d.Comments.Comments {
|
|
printRawComment(out, c, "")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func printHumanComment(opts *ViewOptions, out io.Writer, c client.DiscussionComment, indent string) error {
|
|
cs := opts.IO.ColorScheme()
|
|
now := opts.Now()
|
|
|
|
header := fmt.Sprintf("%s%s commented %s",
|
|
indent,
|
|
cs.Bold(c.Author.Login),
|
|
text.FuzzyAgo(now, c.CreatedAt),
|
|
)
|
|
if c.IsAnswer {
|
|
header += " " + cs.Green("✓ Answer")
|
|
}
|
|
fmt.Fprintln(out, header)
|
|
|
|
if c.Body != "" {
|
|
md, err := markdown.Render(c.Body,
|
|
markdown.WithTheme(opts.IO.TerminalTheme()),
|
|
markdown.WithWrap(opts.IO.TerminalWidth()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if indent != "" {
|
|
md = text.Indent(md, indent)
|
|
}
|
|
fmt.Fprint(out, md)
|
|
}
|
|
|
|
if reactions := reactionGroupList(c.ReactionGroups); reactions != "" {
|
|
fmt.Fprintf(out, "%s%s\n", indent, reactions)
|
|
}
|
|
|
|
fmt.Fprintln(out)
|
|
|
|
for _, reply := range c.Replies.Comments {
|
|
if err := printHumanComment(opts, out, reply, indent+" "); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if shown := len(c.Replies.Comments); shown < c.Replies.TotalCount {
|
|
directionLabel := "more"
|
|
if c.Replies.Direction == client.DiscussionCommentListDirectionForward {
|
|
directionLabel = "newer"
|
|
} else if c.Replies.Direction == client.DiscussionCommentListDirectionBackward {
|
|
directionLabel = "older"
|
|
}
|
|
fmt.Fprintf(out, "%s %s\n\n", indent, cs.Muted(fmt.Sprintf("And %d %s replies", c.Replies.TotalCount-shown, directionLabel)))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func printRawComment(out io.Writer, c client.DiscussionComment, indent string) {
|
|
answer := ""
|
|
if c.IsAnswer {
|
|
answer = "\tanswer"
|
|
}
|
|
fmt.Fprintf(out, "%scomment:\t%s\t%s\t%s%s\n", indent, c.Author.Login, c.CreatedAt.Format(time.RFC3339), c.URL, answer)
|
|
fmt.Fprintf(out, "%s--\n", indent)
|
|
if indent != "" {
|
|
fmt.Fprint(out, text.Indent(c.Body, indent))
|
|
} else {
|
|
fmt.Fprint(out, c.Body)
|
|
}
|
|
fmt.Fprintln(out)
|
|
|
|
for _, reply := range c.Replies.Comments {
|
|
printRawComment(out, reply, indent+" ")
|
|
}
|
|
}
|
|
|
|
func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string {
|
|
if len(labels) == 0 {
|
|
return ""
|
|
}
|
|
|
|
sortedLabels := slices.Clone(labels)
|
|
slices.SortStableFunc(sortedLabels, func(i, j client.DiscussionLabel) int {
|
|
return strings.Compare(i.Name, j.Name)
|
|
})
|
|
|
|
names := make([]string, len(sortedLabels))
|
|
for i, l := range sortedLabels {
|
|
if cs == nil {
|
|
names[i] = l.Name
|
|
} else {
|
|
names[i] = cs.Label(l.Color, l.Name)
|
|
}
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|