From f6a0d18b8acf360dcbe0e324753cbc08bf44512e Mon Sep 17 00:00:00 2001 From: bircni Date: Mon, 12 Oct 2026 00:05:20 +0200 Subject: [PATCH] feat(search): gh-style issue and pull request search tables search issue and search pr printed rows without a header, showed merged pull requests as closed and never said that more results exist. They now show REPO#ID TITLE STATE AUTHOR UPDATED under a context line that counts the matches and points at the next page, are also search issues and search prs, take -s -L -A -l -m and @me, and use --until for --before, which still works. Search results gain an author JSON field. Signed-off-by: bircni --- internal/app/app_test.go | 2 - internal/app/args.go | 9 --- internal/app/discussion_search.go | 83 ++++++++++++++++++-------- internal/app/discussion_search_test.go | 33 ++++++++++ internal/app/validation_test.go | 2 +- internal/services/search.go | 49 ++++++++------- 6 files changed, 120 insertions(+), 58 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f09c5f6e..b6655756 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -418,8 +418,6 @@ const ( shortsToFix = ` ` examplesToWrite = ` - search issue - search pr ` longsToWrite = ` ` diff --git a/internal/app/args.go b/internal/app/args.go index 1bc9a1ed..44b0e7c7 100644 --- a/internal/app/args.go +++ b/internal/app/args.go @@ -62,15 +62,6 @@ func trimNames(lists ...[]string) { } } -// labelFilter splits a comma-separated label filter, trimming entries and rejecting blanks. -func labelFilter(raw string) ([]string, error) { - if raw == "" { - return nil, nil - } - names := strings.Split(raw, ",") - return names, requireNames("label", "", names) -} - // oneOf rejects an enum flag value outside allowed, which needs at least two entries. func oneOf(flag, value string, allowed ...string) error { if slices.Contains(allowed, value) { diff --git a/internal/app/discussion_search.go b/internal/app/discussion_search.go index 62a6803e..6c39d647 100644 --- a/internal/app/discussion_search.go +++ b/internal/app/discussion_search.go @@ -15,17 +15,23 @@ import ( ) func (a *application) discussionSearchCommand(pulls bool) *cobra.Command { - noun := "issue" + noun, nouns, alias := "issue", "issues", "issues" if pulls { - noun = "pr" + noun, nouns, alias = "pr", "pull requests", "prs" } var opts services.DiscussionSearchOptions + var labels []string var updated timeRange opts.Pulls = pulls - command := &cobra.Command{Use: noun + " [query]...", Short: "Search " + noun + "s across the selected instance or repository", RunE: func(cmd *cobra.Command, args []string) error { - if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil { - return err - } + example := fmt.Sprintf(`$ tea search %[1]s "login error" +$ tea search %[1]s --author @me --state all +# Search one repository; its labels and milestones are checked +$ tea search %[1]s crash -R alice/project --label bug --milestone v1.0`, noun) + if pulls { + example += "\n$ tea search pr --review-requested" + } + command := &cobra.Command{Use: noun + " [...]", Aliases: []string{alias}, Short: "Search " + nouns, Long: fmt.Sprintf(`Search the %[1]s of every repository you can access on the instance, or of one +repository with --repo. Words of the query match titles and bodies.`, nouns), Example: example, RunE: func(cmd *cobra.Command, args []string) error { if opts.Author != "" && opts.Authored { return clierr.Wrap(clierr.Usage, errors.New("--author and --authored are mutually exclusive")) } @@ -33,8 +39,7 @@ func (a *application) discussionSearchCommand(pulls bool) *cobra.Command { if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil { return err } - labelNames, err := labelFilter(opts.Labels) - if err != nil { + if err := requireNames("label", "", labels); err != nil { return err } milestones, err := nameFilter("milestone", opts.Milestones) @@ -54,42 +59,68 @@ func (a *application) discussionSearchCommand(pulls bool) *cobra.Command { if opts.Team != "" && target.Repo != "" { return clierr.Wrap(clierr.Usage, errors.New("--team requires instance-wide search; omit --repo or TEA_REPO")) } + opts.Author = target.expandMe(opts.Author) if target.Repo != "" { - if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil { + if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labels, milestones); err != nil { return err } - } else if len(labelNames)+len(milestones) > 0 { - opts.Labels, opts.Milestones = strings.Join(labelNames, ","), strings.Join(milestones, ",") + } else if len(labels)+len(milestones) > 0 { + opts.Labels, opts.Milestones = strings.Join(labels, ","), strings.Join(milestones, ",") if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "warning: instance-wide search ignores --label and --milestone values that match nothing; pass --repo to check them"); err != nil { return err } } opts.Query = strings.Join(args, " ") - items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Search(cmd.Context(), opts) + page, err := (services.Issues{Client: target.client, Repository: target.Repo}).SearchPage(cmd.Context(), opts) if err != nil { return err } - return writeList(a, cmd, items, searchRow) + where := "on " + strings.TrimPrefix(strings.TrimPrefix(target.Host, "https://"), "http://") + if target.Repo != "" { + where = "in " + target.Repo + } + if opts.Query != "" { + where = fmt.Sprintf("matching %q %s", opts.Query, where) + } + what := nouns + if opts.State != "all" { + what = opts.State + " " + nouns + } + return writeTable(a, cmd, page.Items, listSpec[services.SearchItem]{ + Columns: []terminal.Column{{Header: "Repo#ID"}, {Header: "Title", Flex: true}, {Header: "State"}, {Header: "Author"}, {Header: "Updated"}}, + Row: searchRow, Empty: "no " + what + " " + where, Context: what + " " + where, + }, servicePage(page, opts.Page)) }} flags := command.Flags() pageFlags(command, &opts.Limit, &opts.Page) - flags.StringVar(&opts.State, "state", "open", "State: open, closed, all") - flags.StringVar(&opts.Owner, "owner", "", "Repository owner") - flags.StringVar(&opts.Team, "team", "", "Organization team name (instance search; requires --owner)") - flags.StringVar(&opts.Author, "author", "", "Issue/PR author") - flags.StringVar(&opts.Labels, "label", "", "Comma-separated label names") - flags.StringVar(&opts.Milestones, "milestone", "", "Comma-separated milestone names") - updated.register(command, "since", "Updated since a date, timestamp, or duration ago", "before", "Updated before a date, timestamp, or duration ago") - flags.BoolVar(&opts.Assigned, "assigned", false, "Assigned to the selected account") - flags.BoolVar(&opts.Authored, "authored", false, "Created by the selected account") - flags.BoolVar(&opts.Mentioned, "mentioned", false, "Mentioning the selected account") + enumFlag(command, &opts.State, "state", "s", "open", "Filter by state", "open", "closed", "all") + flags.StringVar(&opts.Owner, "owner", "", "Search only the repositories of this `owner`") + flags.StringVar(&opts.Team, "team", "", "Search only the repositories of this organization `team` (requires --owner)") + flags.StringVarP(&opts.Author, "author", "A", "", "Filter by author; @me for yourself") + flags.StringSliceVarP(&labels, "label", "l", nil, "Filter by label `names`") + flags.StringVarP(&opts.Milestones, "milestone", "m", "", "Filter by milestone `titles`, comma-separated") + updated.register(command, "since", "Updated since a date, timestamp, or duration ago", "until", "Updated before a date, timestamp, or duration ago") + flagAliases(command, map[string]string{"before": "until"}) + flags.BoolVar(&opts.Assigned, "assigned", false, "Assigned to you") + flags.BoolVar(&opts.Authored, "authored", false, "Created by you") + flags.BoolVar(&opts.Mentioned, "mentioned", false, "Mentioning you") if pulls { - flags.BoolVar(&opts.ReviewRequested, "review-requested", false, "Requesting review from the selected account (instance search)") - flags.BoolVar(&opts.Reviewed, "reviewed", false, "Reviewed by the selected account (instance search)") + flags.BoolVar(&opts.ReviewRequested, "review-requested", false, "Requesting your review (instance-wide search)") + flags.BoolVar(&opts.Reviewed, "reviewed", false, "Reviewed by you (instance-wide search)") } + _ = command.RegisterFlagCompletionFunc("label", a.completeLabels()) + _ = command.RegisterFlagCompletionFunc("milestone", commaSeparated(a.completeMilestones())) + _ = command.RegisterFlagCompletionFunc("author", a.completeUsers()) + _ = command.RegisterFlagCompletionFunc("owner", a.completeOrgs()) return command } func searchRow(item services.SearchItem) []terminal.Cell { - return []terminal.Cell{{Text: fmt.Sprintf("%s#%d", item.Repository, item.Number), Link: item.URL}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}} + return []terminal.Cell{ + {Text: fmt.Sprintf("%s#%d", item.Repository, item.Number), Link: item.URL, Color: stateColors[item.State]}, + {Text: item.Title}, + {Text: item.State, Color: stateColors[item.State]}, + {Text: item.Author}, + {Time: terminal.ParseTime(item.UpdatedAt)}, + } } diff --git a/internal/app/discussion_search_test.go b/internal/app/discussion_search_test.go index 2283f95b..ff7ad3eb 100644 --- a/internal/app/discussion_search_test.go +++ b/internal/app/discussion_search_test.go @@ -6,6 +6,7 @@ package app import ( "context" "net/http" + "strings" "testing" "time" @@ -64,3 +65,35 @@ func TestSearchIssueAcceptsFlexibleTimeBounds(t *testing.T) { require.JSONEq(t, `[]`, out.String()) require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"search", "issue", "--since", "invalid"}))) } + +func TestSearchTables(t *testing.T) { + var queries []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/issues/search", r.URL.Path) + queries = append(queries, r.URL.RawQuery) + if r.URL.Query().Get("q") == "nothing" { + _, _ = w.Write([]byte(`[]`)) + return + } + w.Header().Set("X-Total-Count", "7") + _, _ = w.Write([]byte(`[{"number":14,"title":"Merged","state":"closed","pull_request":{"merged":true},"user":{"login":"bob"},"updated_at":"2026-10-11T18:00:00Z","repository":{"full_name":"alice/project"}},` + + `{"number":3,"title":"Open","state":"open","user":{"login":"alice"},"updated_at":"2026-10-10T20:00:00Z","repository":{"full_name":"acme/service"}}]`)) + })) + defer server.Close() + deps, out, diagnostics := serverDeps(t, server.URL) + deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) } + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1", "TEA_HYPERLINKS": "never"}) + deps.Streams.OutTTY = true + require.NoError(t, Execute(context.Background(), deps, []string{"search", "prs", "login", "-s", "all", "-L", "2", "--author", "@me"})) + host := strings.TrimPrefix(server.URL, "http://") + require.Equal(t, "Showing 2 of 7 pull requests matching \"login\" on "+host+" ยท next: --page 2\n\n"+ + "REPO#ID TITLE STATE AUTHOR UPDATED\n"+ + "alice/project#14 Merged merged bob about 2 hours ago\n"+ + "acme/service#3 Open open alice about 1 day ago\n", out.String()) + require.Contains(t, queries[0], "created_by=alice") + require.Contains(t, queries[0], "type=pulls") + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"search", "issues", "nothing"})) + require.Empty(t, out.String()) + require.Equal(t, "no open issues matching \"nothing\" on "+host+"\n", diagnostics.String()) +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 92477c7d..5b403c29 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -189,7 +189,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"search", "issue", "query", "--author", "alice", "--authored"}, "--author and --authored are mutually exclusive"}, {[]string{"search", "issue", "--label", ","}, "label names cannot be empty"}, {[]string{"search", "pr", "query", "--since", "invalid"}, "--since: use a date, RFC3339 timestamp, or duration such as 24h"}, - {[]string{"search", "pr", "query", "--since", "2026-01-02T00:00:00Z", "--before", "2026-01-01T00:00:00Z"}, "--since must not be later than --before"}, + {[]string{"search", "pr", "query", "--since", "2026-01-02T00:00:00Z", "--before", "2026-01-01T00:00:00Z"}, "--since must not be later than --until"}, {[]string{"search", "issue", "--since="}, "invalid --since: use a date"}, {[]string{"search", "repo", "query", "--sort", "invalid"}, "--sort must be alpha, created, updated, size, or id"}, {[]string{"search", "repo", "query", "--order", "invalid"}, "--order must be asc or desc"}, diff --git a/internal/services/search.go b/internal/services/search.go index 8b6cbc1c..3e6a4a67 100644 --- a/internal/services/search.go +++ b/internal/services/search.go @@ -6,8 +6,8 @@ package services import ( "context" "fmt" + "net/http" "net/url" - "strconv" "time" gitea "gitea.dev/sdk" @@ -25,6 +25,7 @@ type SearchItem struct { State string `json:"state"` Type string `json:"type"` Repository string `json:"repository"` + Author string `json:"author"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -39,12 +40,18 @@ type DiscussionSearchOptions struct { // Search searches one repository or accessible repositories across the selected instance. func (s Issues) Search(ctx context.Context, opts DiscussionSearchOptions) ([]SearchItem, error) { + page, err := s.SearchPage(ctx, opts) + return page.Items, err +} + +// SearchPage is Search with what the server reports about further results. +func (s Issues) SearchPage(ctx context.Context, opts DiscussionSearchOptions) (Page[SearchItem], error) { kind, resultType := gitea.IssueTypeIssue, "issue" if opts.Pulls { kind, resultType = gitea.IssueTypePull, "pr" } if opts.Team != "" && opts.Owner == "" { - return nil, fmt.Errorf("team search requires an organization owner") + return Page[SearchItem]{}, fmt.Errorf("team search requires an organization owner") } search := s.searchInstance if s.Repository != "" { @@ -52,33 +59,34 @@ func (s Issues) Search(ctx context.Context, opts DiscussionSearchOptions) ([]Sea } issues, err := search(ctx, opts, kind) if err != nil { - return nil, err + return Page[SearchItem]{}, err } - return convertAll(issues, func(issue *gitea.Issue) SearchItem { + items := convertAll(issues.Items, func(issue *gitea.Issue) SearchItem { repository := s.Repository if issue.Repository != nil { repository = issue.Repository.FullName } return SearchItem{ ID: issue.ID, Number: issue.Index, Title: issue.Title, Body: issue.Body, URL: issue.HTMLURL, State: discussionState(issue), - Type: resultType, Repository: repository, CreatedAt: timestamp(issue.Created), UpdatedAt: timestamp(issue.Updated), + Type: resultType, Repository: repository, Author: login(issue.Poster), CreatedAt: timestamp(issue.Created), UpdatedAt: timestamp(issue.Updated), } - }), nil + }) + return Page[SearchItem]{Items: items, More: issues.More, Total: issues.Total, Counted: issues.Counted}, nil } -func (s Issues) searchRepository(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) ([]*gitea.Issue, error) { +func (s Issues) searchRepository(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) (Page[*gitea.Issue], error) { if opts.ReviewRequested || opts.Reviewed { - return nil, &CapabilityError{Feature: "repository-scoped review search", Reason: "Gitea supports review filters on instance-wide search only; omit --repo or TEA_REPO"} + return Page[*gitea.Issue]{}, &CapabilityError{Feature: "repository-scoped review search", Reason: "Gitea supports review filters on instance-wide search only; omit --repo or TEA_REPO"} } if opts.Team != "" { - return nil, &CapabilityError{Feature: "repository-scoped team search", Reason: "Gitea supports team filters on instance-wide search only; omit --repo or TEA_REPO"} + return Page[*gitea.Issue]{}, &CapabilityError{Feature: "repository-scoped team search", Reason: "Gitea supports team filters on instance-wide search only; omit --repo or TEA_REPO"} } owner, repo, err := splitRepository(s.Repository) if err != nil { - return nil, err + return Page[*gitea.Issue]{}, err } query := gitea.ListIssueOption{ - ListOptions: listOptions(opts.Limit, opts.Page), State: gitea.StateType(opts.State), Type: kind, KeyWord: opts.Query, + State: gitea.StateType(opts.State), Type: kind, KeyWord: opts.Query, Labels: filterValues(opts.Labels), Milestones: filterValues(opts.Milestones), CreatedBy: opts.Author, Owner: opts.Owner, } if opts.Since != nil { @@ -90,7 +98,7 @@ func (s Issues) searchRepository(ctx context.Context, opts DiscussionSearchOptio if opts.Authored || opts.Assigned || opts.Mentioned { account, err := CurrentAccount(ctx, s.Client) if err != nil { - return nil, err + return Page[*gitea.Issue]{}, err } if opts.Authored { query.CreatedBy = account @@ -102,14 +110,17 @@ func (s Issues) searchRepository(ctx context.Context, opts DiscussionSearchOptio query.MentionedBy = account } } - issues, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) { - return c.Issues.ListRepoIssues(ctx, owner, repo, query) + return listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Issue, http.Header, error) { + query.ListOptions = listOptions(limit, page) + issues, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) { + return c.Issues.ListRepoIssues(ctx, owner, repo, query) + }) + return issues, headerOf(resp), err }) - return issues, err } -func (s Issues) searchInstance(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) ([]*gitea.Issue, error) { - query := url.Values{"q": {opts.Query}, "state": {opts.State}, "type": {string(kind)}, "limit": {strconv.Itoa(opts.Limit)}, "page": {strconv.Itoa(opts.Page)}} +func (s Issues) searchInstance(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) (Page[*gitea.Issue], error) { + query := url.Values{"q": {opts.Query}, "state": {opts.State}, "type": {string(kind)}} for key, value := range map[string]string{"owner": opts.Owner, "team": opts.Team, "created_by": opts.Author, "labels": opts.Labels, "milestones": opts.Milestones} { if value != "" { query.Set(key, value) @@ -125,8 +136,6 @@ func (s Issues) searchInstance(ctx context.Context, opts DiscussionSearchOptions query.Set(key, "true") } } - var issues []*gitea.Issue // TODO(sdk): ListIssueOption lacks the assigned, created, mentioned, review_requested and reviewed flags. - err := getJSON(ctx, s.Client, "repos/issues/search?"+query.Encode(), &issues) - return issues, err + return rawPage[*gitea.Issue](ctx, s.Client, "repos/issues/search", query, opts.Limit, opts.Page) }