diff --git a/internal/app/action_runs.go b/internal/app/action_runs.go index 1c358d99..ac22bdec 100644 --- a/internal/app/action_runs.go +++ b/internal/app/action_runs.go @@ -78,6 +78,9 @@ $ tea run list --since 24h --json id,status,conclusion,workflow`, opts.Workflow = workflow.ID } page, err := service.RunsPage(cmd.Context(), opts) + if errors.Is(err, services.ErrRunScanExhausted) { + return fmt.Errorf("%w; narrow the search with --branch, --workflow or --status", err) + } if err != nil { return err } diff --git a/internal/app/api.go b/internal/app/api.go index 17ed5a4a..e0c3e938 100644 --- a/internal/app/api.go +++ b/internal/app/api.go @@ -256,7 +256,7 @@ func (a *application) apiCommand() *cobra.Command { response.tty = a.deps.Streams.OutTTY response.color = response.tty && a.colorEnabled() } - return (services.API{Client: target.client}).Visit(cmd.Context(), services.APIOptions{Endpoint: endpoint, Method: method, Body: body, Headers: headers, Paginate: paginate, MaxPages: maxPages}, func(r *http.Response) error { + err = (services.API{Client: target.client}).Visit(cmd.Context(), services.APIOptions{Endpoint: endpoint, Method: method, Body: body, Headers: headers, Paginate: paginate, MaxPages: maxPages}, func(r *http.Response) error { if verbose { if err := writeRequestHead(cmd.ErrOrStderr(), r.Request); err != nil { return err @@ -273,6 +273,10 @@ func (a *application) apiCommand() *cobra.Command { } return response.write(destination, r) }) + if errors.Is(err, services.ErrMaxPages) { + return errors.New("API pagination exceeded --max-pages") + } + return err }) }} cmd.Flags().StringVarP(&method, "method", "X", "GET", "HTTP `method`") diff --git a/internal/app/discussion_search.go b/internal/app/discussion_search.go index 6c39d647..08487051 100644 --- a/internal/app/discussion_search.go +++ b/internal/app/discussion_search.go @@ -56,8 +56,8 @@ repository with --repo. Words of the query match titles and bodies.`, nouns), Ex if opts.Team != "" && opts.Owner == "" { return clierr.Wrap(clierr.Usage, errors.New("--team requires --owner")) } - if opts.Team != "" && target.Repo != "" { - return clierr.Wrap(clierr.Usage, errors.New("--team requires instance-wide search; omit --repo or TEA_REPO")) + if (opts.Team != "" || opts.ReviewRequested || opts.Reviewed) && target.Repo != "" { + return clierr.Wrap(clierr.Usage, errors.New("--team, --review-requested and --reviewed require instance-wide search; omit --repo or TEA_REPO")) } opts.Author = target.expandMe(opts.Author) if target.Repo != "" { diff --git a/internal/app/labels.go b/internal/app/labels.go index 650b5ea3..cb8e9453 100644 --- a/internal/app/labels.go +++ b/internal/app/labels.go @@ -182,7 +182,7 @@ $ tea label create bug -c ee0701 --force`, Args: usageArgs(cobra.MaximumNArgs(1) } item, updated, err := s.CreateUnique(cmd.Context(), existing, opts, force) if err != nil { - return err + return forceHint(err) } return a.reportLabel(cmd, s, item, updated) }} @@ -220,6 +220,14 @@ func (a *application) askLabelDetails(cmd *cobra.Command, opts *services.LabelOp return nil } +// forceHint points at --force when a label exists already. +func forceHint(err error) error { + if conflict, ok := errors.AsType[*services.ConflictError](err); ok && conflict.Reason == services.ReasonExists { + conflict.Hint = "pass --force to update it" + } + return err +} + // reportLabel reports a created or updated label as JSON or with a ✓ line. func (a *application) reportLabel(cmd *cobra.Command, s services.Labels, item services.Label, updated bool) error { verb := "Created" @@ -336,6 +344,7 @@ func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows [ outcome := map[string]string{} results, failures := applyBatch(cmd, rows, func(row services.LabelOptions) string { return "label " + strconv.Quote(*row.Name) }, func(row services.LabelOptions) (services.Label, error) { item, updated, err := s.CreateUnique(cmd.Context(), existing, row, force) + err = forceHint(err) switch { case err != nil: return item, err diff --git a/internal/services/action_runs.go b/internal/services/action_runs.go index 4ec822a6..5897280c 100644 --- a/internal/services/action_runs.go +++ b/internal/services/action_runs.go @@ -100,6 +100,9 @@ type RunListOptions struct { // RunStatuses are the values Gitea accepts as run status filters. var RunStatuses = []string{"queued", "in_progress", "waiting", "requested", "pending", "action_required", "completed", "success", "failure", "cancelled", "skipped", "timed_out", "neutral"} //nolint:misspell // Gitea API spelling. +// ErrRunScanExhausted reports time filters that matched too few of the runs a scan may read. +var ErrRunScanExhausted = fmt.Errorf("time filters scanned %d runs without exhausting the history", maxRunScanPages*100) + // Validate rejects unsupported status filters before performing API requests. func (o RunListOptions) Validate() error { if o.Limit < 1 || o.Limit > 1000 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit { @@ -107,7 +110,7 @@ func (o RunListOptions) Validate() error { } for _, status := range o.Statuses { if !slices.Contains(RunStatuses, status) { - return fmt.Errorf("unsupported run status %q; see tea run list --help", status) + return fmt.Errorf("unsupported run status %q", status) } } return nil @@ -173,7 +176,7 @@ func (s Actions) runs(ctx context.Context, opts RunListOptions) (Page[Run], erro return Page[Run]{Items: result}, nil } } - return Page[Run]{}, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100) + return Page[Run]{}, ErrRunScanExhausted } func (s Actions) runsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) { diff --git a/internal/services/actions_test.go b/internal/services/actions_test.go index a1ff24b1..026bec51 100644 --- a/internal/services/actions_test.go +++ b/internal/services/actions_test.go @@ -275,7 +275,7 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) { defer server.Close() service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} _, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 1, Until: &until}) - require.ErrorContains(t, err, "narrow the search") + require.ErrorIs(t, err, ErrRunScanExhausted) require.Equal(t, maxRunScanPages, pages) } diff --git a/internal/services/api.go b/internal/services/api.go index cc2ef647..3f946d8d 100644 --- a/internal/services/api.go +++ b/internal/services/api.go @@ -29,6 +29,9 @@ type APIOptions struct { MaxPages int } +// ErrMaxPages reports a paginated request that has more pages than APIOptions.MaxPages allows. +var ErrMaxPages = errors.New("API pagination exceeded the page limit") + func nextPageLinks(headers []string) ([]string, error) { result := []string{} for _, header := range headers { @@ -141,7 +144,7 @@ func (s API) Visit(ctx context.Context, options APIOptions, visit func(*http.Res return nil } if page+1 >= options.MaxPages { - return errors.New("API pagination exceeded --max-pages") + return ErrMaxPages } if _, err := s.Client.URL(next); err != nil { return err diff --git a/internal/services/label_selection.go b/internal/services/label_selection.go index 6137f035..edc2c451 100644 --- a/internal/services/label_selection.go +++ b/internal/services/label_selection.go @@ -55,7 +55,7 @@ func (s Labels) CreateUnique(ctx context.Context, existing []Label, opts LabelOp return Label{}, false, err } if !update { - return Label{}, false, &ConflictError{Reason: ReasonExists, Kind: "label", Key: strconv.Quote(current.Name), Scope: s.Scope(), Hint: "pass --force to update it"} + return Label{}, false, &ConflictError{Reason: ReasonExists, Kind: "label", Key: strconv.Quote(current.Name), Scope: s.Scope()} } opts.Name = nil label, err := s.Edit(ctx, current.ID, opts) @@ -68,7 +68,7 @@ func selectLabelNames(scope string, names []string, matches map[string][]int64) for _, name := range names { candidates := matches[name] if len(candidates) == 0 { - return nil, fmt.Errorf("label %q does not exist; use tea label list", name) + return nil, NotFound("label", strconv.Quote(name), scope) } if len(candidates) > 1 { return nil, &AmbiguousError{Kind: "label", Key: strconv.Quote(name), Scope: scope, IDs: candidates} @@ -148,7 +148,7 @@ func (s Labels) RequireFilterNames(ctx context.Context, names []string) error { } for _, name := range names { if !known[name] { - return fmt.Errorf("label %q does not exist in %s; issue filters match repository labels only, see tea label list", name, s.Repository) + return fmt.Errorf("label %q does not exist in %s; issue filters match repository labels only", name, s.Repository) } } return nil diff --git a/internal/services/label_selection_test.go b/internal/services/label_selection_test.go index 2cc58647..9f876e86 100644 --- a/internal/services/label_selection_test.go +++ b/internal/services/label_selection_test.go @@ -36,7 +36,7 @@ func TestLabelSelectionReadsCappedPagesAndPreservesOrder(t *testing.T) { require.Equal(t, []int64{2, 1}, ids) require.Equal(t, 3, reads) _, err = service.ResolveIssueNames(context.Background(), []string{"missing"}) - require.ErrorContains(t, err, "does not exist") + require.ErrorContains(t, err, "not found") before := reads _, err = service.ResolveIssueNames(context.Background(), []string{" "}) require.ErrorContains(t, err, "cannot be empty") @@ -89,5 +89,5 @@ func TestScopedLabelNamesDoNotCollideWithLiteralPrefixes(t *testing.T) { require.NoError(t, err) require.Equal(t, []int64{1, 2, 3}, ids) _, err = service.ResolveIssueNames(context.Background(), []string{"org:bug"}) - require.ErrorContains(t, err, "does not exist") + require.ErrorContains(t, err, "not found") } diff --git a/internal/services/search.go b/internal/services/search.go index 3e6a4a67..760ffcab 100644 --- a/internal/services/search.go +++ b/internal/services/search.go @@ -75,11 +75,8 @@ func (s Issues) SearchPage(ctx context.Context, opts DiscussionSearchOptions) (P } func (s Issues) searchRepository(ctx context.Context, opts DiscussionSearchOptions, kind gitea.IssueType) (Page[*gitea.Issue], error) { - if opts.ReviewRequested || opts.Reviewed { - 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 Page[*gitea.Issue]{}, &CapabilityError{Feature: "repository-scoped team search", Reason: "Gitea supports team filters on instance-wide search only; omit --repo or TEA_REPO"} + if opts.ReviewRequested || opts.Reviewed || opts.Team != "" { + return Page[*gitea.Issue]{}, &CapabilityError{Feature: "repository-scoped review or team search", Reason: "Gitea supports review and team filters on instance-wide search only"} } owner, repo, err := splitRepository(s.Repository) if err != nil {