Add discussion list command#13084
Conversation
b23bc28 to
0f22710
Compare
Add the discussion list command with full support for: - Listing discussions with state, category, answered, and order filters - Searching discussions by author and labels (uses Search API) - Category resolution by slug or name (case-insensitive) - TTY and non-TTY table output with colored IDs and labels - JSON output with totalCount envelope - Web mode (--web) to open discussions in browser - Pagination for large result sets Implement List, Search, and ListCategories methods on the DiscussionClient. List and Search use plain text GraphQL for flexible variable handling. ListCategories uses safely typed GraphQL via shurcooL/githubv4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
0f22710 to
90449b5
Compare
babakks
left a comment
There was a problem hiding this comment.
Thanks for the PR, @maxbeizer! 🍻 It's great.
Note that some of these comments are best to be resolved in a follow up PR, as they'd break the base for the discussion view PR.
Summary of my comments (you can find the details in their corresponding comments):
- Add tests for discussion client methods.
- Add
--searchoption. - Add
--sortand--orderoptions. - Add
--afteroption for further pagination calls, and thenextfield in JSON output. We should basically do what we planned for pagination indiscussion view(in #12810 (comment)). - Some domain-vs-API and domain-vs-CLI adaptions.
- Question around having
hasDiscussionsEnablednext todiscussions; does that even work on repos where discussions are disabled. - A bit around
--helpdocs;(preview)note, examples, and command usage format.
Further questions, but not ready to comment/share for now
- Shouldn't
ClosedAtandAnswerChosenAtfields benull-able? - What happens to ghost users in
author.loginquery? - See if we can replace the plain text GQL query with a strongly-typed one.
| @@ -19,12 +22,314 @@ func NewDiscussionClient(httpClient *http.Client) DiscussionClient { | |||
| } | |||
| } | |||
There was a problem hiding this comment.
TBF in a follow-up.
Constructor functions, like NewDiscussionClient here, are expected to return a pointer to a struct/type and not an interface. I know here we can't just capitalise and export discussionClient struct as it would collide with DiscussionClient interface. So, my suggestion is to rename the interface to Discussions and then export the implementation as DiscussionClient. It's not perfect and could be seen as misleading, but I think I'm okay with it (TBF it's better than what we did in agent-task where the interface and the struct names are just different in casing).
Actually, the problem is we're defining both the interface and the implementation in the same package and that's why our hands are tied here. Interfaces make more sense to be defined at the consumer side (especially, with duck-typing, as in Go), but I prefer to keep them in the same package for consistency with other commands.
| orderField := "UPDATED_AT" | ||
| orderDir := "DESC" | ||
| if filters.OrderBy != "" { | ||
| orderField = strings.ToUpper(filters.OrderBy) + "_AT" |
There was a problem hiding this comment.
Let's not rely on such string manipulations to transfer types/values from domain to API types. What we should do is to:
- Define acceptable values for the domain
OrderBYtype as consts intypes.go(no need to define a secondarystringtype, though). - Here, add a
switch-statement to assign theorderFieldtemp (nostrings.*usage). The switch should have adefaultclause that returns an error. This is to prevent half-baked code changes in the future, if any.
| if filters.Direction != "" { | ||
| orderDir = strings.ToUpper(filters.Direction) | ||
| } |
| isTerminal := opts.IO.IsStdoutTTY() | ||
| if isTerminal { | ||
| title := listHeader(ghrepo.FullName(repo), len(discussions), totalCount, opts.State) | ||
| fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) | ||
| } | ||
|
|
||
| printDiscussions(opts, discussions, totalCount) |
There was a problem hiding this comment.
I think we should move all this into printDiscussion, as it's the main decision maker on printing data.
| remaining := totalCount - len(discussions) | ||
| if remaining > 0 { | ||
| fmt.Fprintf(opts.IO.Out, cs.Muted("And %d more\n"), remaining) | ||
| } |
There was a problem hiding this comment.
I know issue list has the same logic to print the remaining lines, but I think it never because both the caller always pass the length of the issues slice as the totalCount to the table renderer function. That's just a fortunate bug, IMO, because we never print that line, especially in non-TTY experience (where we should never print anything that machines cannot read).
Here, it's not a bug because we have a valid totalCount passed in, but we still shouldn't print it in non-TTY mode. So:
| remaining := totalCount - len(discussions) | |
| if remaining > 0 { | |
| fmt.Fprintf(opts.IO.Out, cs.Muted("And %d more\n"), remaining) | |
| } | |
| if remaining := totalCount - len(discussions); isTerminal && remaining > 0 { | |
| fmt.Fprintf(opts.IO.Out, cs.Muted("And %d more\n"), remaining) | |
| } |
|
|
||
| var queryParts []string | ||
| if opts.State != "" && opts.State != "all" { | ||
| queryParts = append(queryParts, "is:"+opts.State) |
There was a problem hiding this comment.
The same interface-to-domain adaption should be done here for state.
| queryParts = append(queryParts, "is:"+opts.State) | ||
| } | ||
| if opts.Author != "" { | ||
| queryParts = append(queryParts, "author:"+opts.Author) |
There was a problem hiding this comment.
Let's quote author value.
| queryParts = append(queryParts, "author:"+opts.Author) | |
| queryParts = append(queryParts, fmt.Sprintf("author:%q", opts.Author)) |
| } | ||
|
|
||
| if len(queryParts) > 0 { | ||
| discussionsURL += "?" + url.Values{"q": {strings.Join(queryParts, " ")}}.Encode() |
There was a problem hiding this comment.
We should also include the --search value here, if any.
maxbeizer
left a comment
There was a problem hiding this comment.
Thanks for the thorough review, @babakks! Really appreciate the detail here. 🙏
I agree with the feedback across the board. Here's my plan for tackling it:
In this PR:
- Domain/API type safety:
*stringfor State, exported consts intypes.go,switchstatements withdefaulterror clauses,toFilterState()helper - Pagination:
--afterflag,DiscussionListResultreturn type with cursor,nextin JSON envelope - Search:
--search/-Sflag, single keyword string, qualifier/keyword terminology,%qquoting - CLI flags: split
--order-byinto--sort+--order,(preview)annotations,Use: "list [flags]",defaultLimitconst, additional examples - Code organization: category lookup →
shared/, single-line errors,pageLimit→remaining, hoisthasDiscussionsEnabledout of loop - Guard clauses:
limit <= 0validation in client methods
In follow-up PRs:
- Interface rename (
DiscussionClient→Discussions) + export struct - Client method tests (following
agent-task/capi/sessions_test.gopattern)
Will investigate:
hasDiscussionsEnabled+discussionsquery behavior on repos with discussions disabled
|
Investigation result for the Querying a repo with discussions disabled ( |
- Export domain consts (FilterStateOpen/Closed, OrderByCreated/Updated,
OrderDirectionAsc/Desc) in types.go
- Change State fields to *string (nil = all states)
- Add DiscussionListResult type with NextCursor for pagination
- Update List/Search signatures: add after param, return result type
- Add limit <= 0 guard clauses in client methods
- Replace strings.ToUpper/ToLower with switch statements + default errors
- Rename pageLimit to remaining, hoist hasDiscussionsEnabled check
- Use qualifier/keyword terminology in search query building
- Quote author values with %q for whitespace safety
- Add Keywords string field (single string, not []string)
- Split --order into --sort {created|updated} and --order {asc|desc}
- Add --search/-S and --after flags
- Add next field in JSON output envelope
- Extract defaultLimit const
- Add toFilterState helper for CLI-to-domain mapping
- Move matchCategory to shared/categories.go with godoc
- Use single-line error messages with sorted slugs
- Add (preview) annotations to Short docs
- Update Use to "list [flags]"
- Add examples for --answered=false, --state all, multi-label
- Update tests for new signatures and new flags
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There's one more case when we fetch the categories, too, and that happens before the actual list query. |
|
Good call! I checked — I think the cleanest fix is to add |
When --category is used, ListCategories runs before the List query. On repos with discussions disabled, it silently returns empty categories, leading to a confusing "must be one of []" error. Now it checks hasDiscussionsEnabled and returns the standard "discussions disabled" error, matching the behavior of List and Search. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The GraphQL Discussion type has a `closed` boolean field, not a
`state` string. Updated the API response struct and GQL fragment
to query `closed` instead of `state`, and derive the domain-level
State string ("OPEN"/"CLOSED") from the boolean in mapDiscussion.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
…tion Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
…ons` Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
Signed-off-by: Babak K. Shandiz <babakks@github.com>
|
@maxbeizer, I went through the code and applied the below changes. I think it's good to be merged now. Please let me know what do you think! 🍻
|
Summary
Implements
gh discussion list— the first user-facing command in the discussion group (PR 2 of 11).Features
repository.discussionsGraphQL query with state, category, answered, sort, and order filterssearch(type: DISCUSSION)when--author,--label, or--searchare provided (same dual-path pattern asgh issue list)--aftercursor flag for paginating through results;nextcursor in JSON output envelope--categoryby slug first, then name (case-insensitive). Errors with sorted available slugs on mismatch. Lives inshared/for reuse by other commands.{"totalCount": N, "discussions": [...], "next": "<cursor>"}with--jqand--templatesupport--webopens discussions in browser, populates search box when filters are presentdiscussionandlistcommands marked as(preview)Flags
Client implementation
Implements
List,Search, andListCategorieson theDiscussionClient:ListandSearchacceptaftercursor param and returnDiscussionListResult(discussions + total count + next cursor)limit <= 0FilterStateOpen/FilterStateClosed), order-by (OrderByCreated/OrderByUpdated), and direction (OrderDirectionAsc/OrderDirectionDesc)switchstatements withdefaulterror clauses for all enum mappings (nostrings.ToUpper)%qquoting for whitespace safetyListCategoriescheckshasDiscussionsEnabledand returns a clear error for repos with discussions disabledTests
17 test cases covering TTY/non-TTY/JSON output (including
nextcursor), web mode, empty results, category resolution, category not found, author filter, label filter, search filter, after cursor, closed state,toFilterStatehelper, and flag parsing (including--sort,--order,--search,--after, and invalid values).What's next
PR 3:
discussion view(without comments)Refs: #12810,