refactor: reuse the existing service and rendering helpers

services.IsNotFound replaces the hand-rolled 404 checks, convertPage
the copied page conversions and lastPathNumber the comment's own
number parser. The List wrappers around ListPage of releases,
branches, wiki pages, secrets and variables go, pull request filters
resolve milestones through Milestones.FindOne like issues, and
localFlagsChanged, boolOrder, Cell.Render, stateBadge and dueText
replace their copies.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 11:22:52 +02:00
1 parent 29f62da006
commit 83924892ff
33 files changed
+85 -212

No files matched your search

+1 -10
View File
@@ -262,7 +262,7 @@ func renderRun(run services.Run, d terminal.Display, hints []string) string {
if !d.TTY { if !d.TTY {
return runRecord(run, d, now) return runRecord(run, d, now)
} }
style := func(cell terminal.Cell) string { return paintActionCell(cell, d.Color) } style := func(cell terminal.Cell) string { return cell.Render(d) }
muted := func(text string) string { return style(terminal.Cell{Text: text, Muted: true}) } muted := func(text string) string { return style(terminal.Cell{Text: text, Muted: true}) }
var b strings.Builder var b strings.Builder
heading := run.Workflow heading := run.Workflow
@@ -321,15 +321,6 @@ func renderRun(run services.Run, d terminal.Display, hints []string) string {
return b.String() return b.String()
} }
// paintActionCell styles a cell's safe text outside a table: its color, muted or bold.
func paintActionCell(cell terminal.Cell, color bool) string {
name := cell.Color
if cell.Muted {
name = "muted"
}
return terminal.Emphasis(terminal.Colorize(name, terminal.SafeLine(cell.Text), color), cell.Bold && color)
}
// jobLines lists jobs, each followed by its steps, for the run view. // jobLines lists jobs, each followed by its steps, for the run view.
func jobLines(jobs []services.Job, now time.Time) [][]terminal.Cell { func jobLines(jobs []services.Job, now time.Time) [][]terminal.Cell {
var rows [][]terminal.Cell var rows [][]terminal.Cell
+5 -7
View File
@@ -8,7 +8,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -17,7 +16,6 @@ import (
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -144,11 +142,11 @@ func (a *application) renderWorkflow(t target, workflow services.Workflow, runs
fields := []terminal.Field{{Label: "File", Value: workflow.ID}, {Label: "Path", Value: workflow.Path}, {Label: "State", Value: workflow.State}} fields := []terminal.Field{{Label: "File", Value: workflow.ID}, {Label: "Path", Value: workflow.Path}, {Label: "State", Value: workflow.State}}
return terminal.View{Title: workflow.Name, Fields: fields, URL: workflow.URL, Body: terminal.Table(runColumns, rows, d)}.Render(d) return terminal.View{Title: workflow.Name, Fields: fields, URL: workflow.URL, Body: terminal.Table(runColumns, rows, d)}.Render(d)
} }
muted := func(text string) string { return paintActionCell(terminal.Cell{Text: text, Muted: true}, d.Color) } muted := func(text string) string { return terminal.Cell{Text: text, Muted: true}.Render(d) }
var b strings.Builder var b strings.Builder
b.WriteString(paintActionCell(terminal.Cell{Text: workflow.Name, Bold: true}, d.Color) + " " + muted(workflow.ID) + "\n") b.WriteString(terminal.Cell{Text: workflow.Name, Bold: true}.Render(d) + " " + muted(workflow.ID) + "\n")
b.WriteString(paintActionCell(workflowState(workflow), d.Color) + " • " + muted(workflow.Path) + "\n\n") b.WriteString(workflowState(workflow).Render(d) + " • " + muted(workflow.Path) + "\n\n")
b.WriteString(paintActionCell(terminal.Cell{Text: "RECENT RUNS", Bold: true}, d.Color) + "\n") b.WriteString(terminal.Cell{Text: "RECENT RUNS", Bold: true}.Render(d) + "\n")
if len(runs) == 0 { if len(runs) == 0 {
b.WriteString(muted("No runs yet") + "\n") b.WriteString(muted("No runs yet") + "\n")
} else { } else {
@@ -315,7 +313,7 @@ func (a *application) dispatchRef(ctx context.Context, t target, ref string) (st
return defaultBranch() return defaultBranch()
} }
_, err = services.Branches{Client: t.client}.View(ctx, t.Repo, branch) _, err = services.Branches{Client: t.client}.View(ctx, t.Repo, branch)
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound { if services.IsNotFound(err) {
fallback, err := defaultBranch() fallback, err := defaultBranch()
if err != nil { if err != nil {
return "", err return "", err
+5 -3
View File
@@ -307,9 +307,11 @@ func argumentName(cmd *cobra.Command, argument string) string {
return noun + " " + text return noun + " " + text
} }
// localFlagsChanged reports whether any command-specific flag was supplied. // localFlagsChanged reports whether a command-specific flag other than those in except was supplied.
func localFlagsChanged(cmd *cobra.Command) bool { func localFlagsChanged(cmd *cobra.Command, except ...string) bool {
changed := false changed := false
cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { changed = changed || flag.Changed }) cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) {
changed = changed || flag.Changed && !slices.Contains(except, flag.Name)
})
return changed return changed
} }
+1 -11
View File
@@ -463,7 +463,7 @@ $ tea auth status --host gitea.com --json`,
rows = append(rows, row) rows = append(rows, row)
} }
slices.SortFunc(rows, func(x, y authStatusRow) int { slices.SortFunc(rows, func(x, y authStatusRow) int {
return cmp.Or(strings.Compare(x.Host, y.Host), -compareBool(x.environment(), y.environment()), strings.Compare(x.User, y.User)) return cmp.Or(strings.Compare(x.Host, y.Host), cmp.Compare(boolOrder(y.environment()), boolOrder(x.environment())), strings.Compare(x.User, y.User))
}) })
if a.options.json != "" { if a.options.json != "" {
return a.writeJSON(cmd, rows) return a.writeJSON(cmd, rows)
@@ -487,16 +487,6 @@ $ tea auth status --host gitea.com --json`,
return status return status
} }
func compareBool(x, y bool) int {
switch {
case x == y:
return 0
case x:
return 1
}
return -1
}
// accountStatus reports a stored account without revealing its credential. // accountStatus reports a stored account without revealing its credential.
func (a *application) accountStatus(ctx context.Context, s settings.Snapshot, instance, user string, verify bool) authStatusRow { func (a *application) accountStatus(ctx context.Context, s settings.Snapshot, instance, user string, verify bool) authStatusRow {
host := s.Hosts.Hosts[instance] host := s.Hosts.Hosts[instance]
+8 -8
View File
@@ -385,8 +385,8 @@ func (a *application) completeUsers() cobra.CompletionFunc {
// completeBranches completes the local branches, then the repository's branches. // completeBranches completes the local branches, then the repository's branches.
func (a *application) completeBranches() cobra.CompletionFunc { func (a *application) completeBranches() cobra.CompletionFunc {
return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Branches{Client: t.client}.List(ctx, t.Repo, completionLimit, 1) items, err := services.Branches{Client: t.client}.ListPage(ctx, t.Repo, completionLimit, 1)
return describe(items, func(item services.Branch) (string, string) { return describe(items.Items, func(item services.Branch) (string, string) {
if item.Protected { if item.Protected {
return item.Name, "protected" return item.Name, "protected"
} }
@@ -406,8 +406,8 @@ func (a *application) completeTags() cobra.CompletionFunc {
// completeReleases completes release tags with their titles. // completeReleases completes release tags with their titles.
func (a *application) completeReleases() cobra.CompletionFunc { func (a *application) completeReleases() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Releases{Client: t.client, Repository: t.Repo}.List(ctx, completionLimit, 1) items, err := services.Releases{Client: t.client, Repository: t.Repo}.ListPage(ctx, completionLimit, 1)
return describe(items, func(item services.Release) (string, string) { return item.Tag, item.Title }), err return describe(items.Items, func(item services.Release) (string, string) { return item.Tag, item.Title }), err
}) })
} }
@@ -493,8 +493,8 @@ func (a *application) completeRuns() cobra.CompletionFunc {
// organization or account selects the repository. // organization or account selects the repository.
func (a *application) completeSecrets(scope func() services.ActionScope) cobra.CompletionFunc { func (a *application) completeSecrets(scope func() services.ActionScope) cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.ActionSecrets{Client: t.client, Scope: actionScope(scope, t)}.List(ctx, completionLimit, 1) items, err := services.ActionSecrets{Client: t.client, Scope: actionScope(scope, t)}.ListPage(ctx, completionLimit, 1)
return describe(items, func(item services.ActionSecret) (string, string) { return item.Name, item.Description }), err return describe(items.Items, func(item services.ActionSecret) (string, string) { return item.Name, item.Description }), err
}) })
} }
@@ -502,8 +502,8 @@ func (a *application) completeSecrets(scope func() services.ActionScope) cobra.C
// an organization or account selects the repository. // an organization or account selects the repository.
func (a *application) completeVariables(scope func() services.ActionScope) cobra.CompletionFunc { func (a *application) completeVariables(scope func() services.ActionScope) cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.ActionVariables{Client: t.client, Scope: actionScope(scope, t)}.List(ctx, completionLimit, 1) items, err := services.ActionVariables{Client: t.client, Scope: actionScope(scope, t)}.ListPage(ctx, completionLimit, 1)
return describe(items, func(item services.ActionVariable) (string, string) { return item.Name, item.Description }), err return describe(items.Items, func(item services.ActionVariable) (string, string) { return item.Name, item.Description }), err
}) })
} }
+1 -3
View File
@@ -8,7 +8,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -18,7 +17,6 @@ import (
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -490,7 +488,7 @@ func issueLabel(n int64) string { return fmt.Sprintf("issue %d", n) }
// itemError words the failure of one item of a batch, which the batch prefixes with the item, such // itemError words the failure of one item of a batch, which the batch prefixes with the item, such
// as "issue 9: not found in alice/x". // as "issue 9: not found in alice/x".
func itemError(err error, scope string) error { func itemError(err error, scope string) error {
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound { if services.IsNotFound(err) {
return explain(err, "not found in %s", scope) return explain(err, "not found in %s", scope)
} }
return err return err
+3 -4
View File
@@ -125,13 +125,12 @@ func milestoneView(t target, now time.Time) func(services.Milestone) terminal.Vi
} }
due := "" due := ""
if item.Deadline != nil { if item.Deadline != nil {
deadline := terminal.ParseTime(*item.Deadline) due = terminal.Timestamp(terminal.ParseTime(*item.Deadline))
due = terminal.Timestamp(deadline) meta = append([]string{"due " + dueText(now, *item.Deadline)}, meta...)
meta = append([]string{"due " + deadline.Format("Jan 2, 2006") + " (" + terminal.RelativeTime(now, deadline) + ")"}, meta...)
} }
return terminal.View{ return terminal.View{
Title: item.Title, ID: "#" + strconv.FormatInt(item.ID, 10), Title: item.Title, ID: "#" + strconv.FormatInt(item.ID, 10),
Badges: []terminal.Cell{{Text: titleCase(item.State), Color: stateColors[item.State]}}, Meta: strings.Join(meta, " • "), Badges: []terminal.Cell{stateBadge(item.State)}, Meta: strings.Join(meta, " • "),
Fields: []terminal.Field{ Fields: []terminal.Field{
{Label: "State", Value: item.State, PipedOnly: true}, {Label: "State", Value: item.State, PipedOnly: true},
{Label: "Due", Value: due, PipedOnly: true}, {Label: "Due", Value: due, PipedOnly: true},
+1 -1
View File
@@ -90,7 +90,7 @@ is not on the remote yet is pushed first, with its upstream set.`, Args: argsExa
return err return err
} }
if milestone != "" && milestone != "0" { if milestone != "" && milestone != "0" {
if options.Milestone, err = milestoneFilter(cmd.Context(), t, milestone); err != nil { if options.Milestone, err = milestoneID(cmd, t, milestone); err != nil {
return err return err
} }
} }
+1 -1
View File
@@ -85,7 +85,7 @@ pull request, or mark it as a draft or ready for review.`, Annotations: pullArgu
var none int64 var none int64
opts.Milestone = &none opts.Milestone = &none
case milestone != "": case milestone != "":
id, err := milestoneFilter(cmd.Context(), t, milestone) id, err := milestoneID(cmd, t, milestone)
if err != nil { if err != nil {
return err return err
} }
+2 -38
View File
@@ -4,9 +4,7 @@
package app package app
import ( import (
"context"
"errors" "errors"
"net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -14,9 +12,7 @@ import (
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
func (a *application) prCommand() *cobra.Command { func (a *application) prCommand() *cobra.Command {
@@ -71,7 +67,7 @@ each pull request, which tea only makes when --json names them.`, Args: argsExac
return err return err
} }
if milestone != "" { if milestone != "" {
if options.Milestone, err = milestoneFilter(cmd.Context(), t, milestone); err != nil { if options.Milestone, err = milestoneID(cmd, t, milestone); err != nil {
return err return err
} }
} }
@@ -91,7 +87,7 @@ each pull request, which tea only makes when --json names them.`, Args: argsExac
noun := map[string]string{"open": "open pull requests", "closed": "closed pull requests", "merged": "merged pull requests", "all": "pull requests"}[state] noun := map[string]string{"open": "open pull requests", "closed": "closed pull requests", "merged": "merged pull requests", "all": "pull requests"}[state]
what := noun + " in " + t.Repo what := noun + " in " + t.Repo
empty := "no " + what empty := "no " + what
if filtered(cmd, "state", "limit", "page", "sort") { if localFlagsChanged(cmd, "state", "limit", "page", "sort") {
empty = "no " + noun + " match your filters in " + t.Repo empty = "no " + noun + " match your filters in " + t.Repo
} }
columns := []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Branch", Flex: true, Min: 12}, {Header: "State", PipedOnly: state == "open"}, {Header: "Author"}, {Header: "Created"}} columns := []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Branch", Flex: true, Min: 12}, {Header: "State", PipedOnly: state == "open"}, {Header: "Author"}, {Header: "Created"}}
@@ -137,22 +133,6 @@ $ tea pr list --json number,title,head --jq '.[] | select(.head == "feature/logi
return cmd return cmd
} }
// changedFlags lists the names of the command's own flags that were given.
func changedFlags(cmd *cobra.Command) []string {
var names []string
cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) {
if flag.Changed {
names = append(names, flag.Name)
}
})
return names
}
// filtered reports whether a flag other than those named, which do not filter, was given.
func filtered(cmd *cobra.Command, unfiltered ...string) bool {
return slices.ContainsFunc(changedFlags(cmd), func(name string) bool { return !slices.Contains(unfiltered, name) })
}
// jsonSelects reports whether --json names one of fields, rather than selecting every field. // jsonSelects reports whether --json names one of fields, rather than selecting every field.
func (a *application) jsonSelects(fields ...string) bool { func (a *application) jsonSelects(fields ...string) bool {
for field := range strings.SplitSeq(a.options.json, ",") { for field := range strings.SplitSeq(a.options.json, ",") {
@@ -163,22 +143,6 @@ func (a *application) jsonSelects(fields ...string) bool {
return false return false
} }
// milestoneFilter resolves a milestone given as an ID or a title (D10): a number is an ID first,
// then a title.
func milestoneFilter(ctx context.Context, t target, raw string) (int64, error) {
service := services.Milestones{Client: t.client, Repository: t.Repo}
if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
_, err := service.View(ctx, id)
if err == nil {
return id, nil
}
if apiErr, ok := errors.AsType[*transport.APIError](err); !ok || apiErr.Status != http.StatusNotFound {
return 0, err
}
}
return service.ResolveName(ctx, raw)
}
func (a *application) pullViewCommand() *cobra.Command { func (a *application) pullViewCommand() *cobra.Command {
var web, comments bool var web, comments bool
cmd := &cobra.Command{Use: "view [<number> | <url> | <branch>]", Short: "View a pull request", Annotations: pullArgumentsAnnotation(nil), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { cmd := &cobra.Command{Use: "view [<number> | <url> | <branch>]", Short: "View a pull request", Annotations: pullArgumentsAnnotation(nil), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
+2 -2
View File
@@ -29,8 +29,8 @@ func (a *application) wikiCommand() *cobra.Command {
// completeWikiPages completes the titles of the repository's wiki pages. // completeWikiPages completes the titles of the repository's wiki pages.
func (a *application) completeWikiPages() cobra.CompletionFunc { func (a *application) completeWikiPages() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
pages, err := services.Wiki{Client: t.client, Repository: t.Repo}.List(ctx, completionLimit, 1) pages, err := services.Wiki{Client: t.client, Repository: t.Repo}.ListPage(ctx, completionLimit, 1)
return describe(pages, func(page services.WikiPage) (string, string) { return page.Title, "" }), err return describe(pages.Items, func(page services.WikiPage) (string, string) { return page.Title, "" }), err
}) })
} }
+1 -1
View File
@@ -82,7 +82,7 @@ func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) (Page
if err != nil { if err != nil {
return Page[Job]{}, err return Page[Job]{}, err
} }
return Page[Job]{Items: convertAll(window.Items, jobFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil return convertPage(window, jobFrom), nil
} }
// WaitForFirstJob polls a freshly dispatched run until the server has created its first job. // WaitForFirstJob polls a freshly dispatched run until the server has created its first job.
+4 -16
View File
@@ -63,13 +63,7 @@ type ActionSecrets struct {
Scope ActionScope Scope ActionScope
} }
// List returns secret metadata only. // ListPage lists a page of secrets, with what the server reports about the other pages.
func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecret, error) {
window, err := s.ListPage(ctx, limit, page)
return window.Items, err
}
// ListPage is List with what the server reports about the other pages.
func (s ActionSecrets) ListPage(ctx context.Context, limit, page int) (Page[ActionSecret], error) { func (s ActionSecrets) ListPage(ctx context.Context, limit, page int) (Page[ActionSecret], error) {
if s.Scope.Account { if s.Scope.Account {
return Page[ActionSecret]{}, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"} return Page[ActionSecret]{}, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"}
@@ -88,7 +82,7 @@ func (s ActionSecrets) ListPage(ctx context.Context, limit, page int) (Page[Acti
}) })
return secrets, headerOf(resp), err return secrets, headerOf(resp), err
}) })
return Page[ActionSecret]{Items: convertAll(window.Items, secretFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err return convertPage(window, secretFrom), err
} }
// Set creates or replaces a secret; the API's PUT operation defines upsert semantics. The result // Set creates or replaces a secret; the API's PUT operation defines upsert semantics. The result
@@ -147,13 +141,7 @@ func (s ActionVariables) repoVariables(name string) string {
return endpoint return endpoint
} }
// List returns a non-nil page of variables. // ListPage lists a page of variables, with what the server reports about the other pages.
func (s ActionVariables) List(ctx context.Context, limit, page int) ([]ActionVariable, error) {
window, err := s.ListPage(ctx, limit, page)
return window.Items, err
}
// ListPage is List with what the server reports about the other pages.
func (s ActionVariables) ListPage(ctx context.Context, limit, page int) (Page[ActionVariable], error) { func (s ActionVariables) ListPage(ctx context.Context, limit, page int) (Page[ActionVariable], error) {
var window Page[*gitea.ActionsVariable] var window Page[*gitea.ActionsVariable]
var err error var err error
@@ -170,7 +158,7 @@ func (s ActionVariables) ListPage(ctx context.Context, limit, page int) (Page[Ac
} else { } else {
window, err = rawPage[*gitea.ActionsVariable](ctx, s.Client, s.repoVariables(""), nil, limit, page) window, err = rawPage[*gitea.ActionsVariable](ctx, s.Client, s.repoVariables(""), nil, limit, page)
} }
return Page[ActionVariable]{Items: convertAll(window.Items, variableFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err return convertPage(window, variableFrom), err
} }
// View retrieves one variable. // View retrieves one variable.
+4 -4
View File
@@ -82,9 +82,9 @@ func TestVariableReadsAndDeletesUseTheirScope(t *testing.T) {
})) }))
service := ActionVariables{Client: client, Scope: scope.scope} service := ActionVariables{Client: client, Scope: scope.scope}
want := ActionVariable{Name: "MODE", Value: "test", Description: "build mode"} want := ActionVariable{Name: "MODE", Value: "test", Description: "build mode"}
items, err := service.List(context.Background(), 20, 2) items, err := service.ListPage(context.Background(), 20, 2)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []ActionVariable{want}, items) require.Equal(t, []ActionVariable{want}, items.Items)
item, err := service.View(context.Background(), "MODE") item, err := service.View(context.Background(), "MODE")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, want, item) require.Equal(t, want, item)
@@ -128,14 +128,14 @@ func TestSecretListReturnsMetadataOnly(t *testing.T) {
assert.Equal(t, "2", r.URL.Query().Get("page")) assert.Equal(t, "2", r.URL.Query().Get("page"))
_, _ = w.Write([]byte(`[{"name":"TOKEN","data":"never-display","description":"deploy","created_at":"2028-01-02T03:04:05Z"},{"name":"OTHER"}]`)) _, _ = w.Write([]byte(`[{"name":"TOKEN","data":"never-display","description":"deploy","created_at":"2028-01-02T03:04:05Z"},{"name":"OTHER"}]`))
})) }))
items, err := (ActionSecrets{Client: client, Scope: scope.scope}).List(context.Background(), 20, 2) items, err := (ActionSecrets{Client: client, Scope: scope.scope}).ListPage(context.Background(), 20, 2)
if scope.scope.Account { if scope.scope.Account {
var unsupported *CapabilityError var unsupported *CapabilityError
require.ErrorAs(t, err, &unsupported) require.ErrorAs(t, err, &unsupported)
return return
} }
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []ActionSecret{{Name: "TOKEN", Description: "deploy", CreatedAt: "2028-01-02T03:04:05Z"}, {Name: "OTHER"}}, items) require.Equal(t, []ActionSecret{{Name: "TOKEN", Description: "deploy", CreatedAt: "2028-01-02T03:04:05Z"}, {Name: "OTHER"}}, items.Items)
}) })
} }
} }
+3 -9
View File
@@ -39,13 +39,7 @@ func branchFrom(branch *gitea.Branch) Branch {
// Branches operates on remote branches and protection rules. // Branches operates on remote branches and protection rules.
type Branches struct{ Client *transport.Client } type Branches struct{ Client *transport.Client }
// List returns a page of remote branches. // ListPage lists a page of branches, with whether more follow and how many there are.
func (s Branches) List(ctx context.Context, repo string, limit, page int) ([]Branch, error) {
window, err := s.ListPage(ctx, repo, limit, page)
return window.Items, err
}
// ListPage is List that also reports whether more branches follow and how many there are.
func (s Branches) ListPage(ctx context.Context, repo string, limit, page int) (Page[Branch], error) { func (s Branches) ListPage(ctx context.Context, repo string, limit, page int) (Page[Branch], error) {
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Branch, http.Header, error) { window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Branch, http.Header, error) {
var headers http.Header var headers http.Header
@@ -59,7 +53,7 @@ func (s Branches) ListPage(ctx context.Context, repo string, limit, page int) (P
if err != nil { if err != nil {
return Page[Branch]{}, err return Page[Branch]{}, err
} }
return Page[Branch]{Items: convertAll(window.Items, branchFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil return convertPage(window, branchFrom), nil
} }
// View retrieves a remote branch. // View retrieves a remote branch.
@@ -143,7 +137,7 @@ func (s Branches) Unprotect(ctx context.Context, repo, rule string) (bool, error
err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) { err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
return c.Repositories.DeleteBranchProtection(ctx, owner, name, rule) return c.Repositories.DeleteBranchProtection(ctx, owner, name, rule)
}) })
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound { if IsNotFound(err) {
if _, err := (Repositories{Client: s.Client}).get(ctx, repo); err != nil { if _, err := (Repositories{Client: s.Client}).get(ctx, repo); err != nil {
return false, err return false, err
} }
+2 -2
View File
@@ -36,12 +36,12 @@ func TestBranchListMapsServerFields(t *testing.T) {
assert.Equal(t, "3", r.URL.Query().Get("page")) assert.Equal(t, "3", r.URL.Query().Get("page"))
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","message":"Fix\n\nbody","timestamp":"2026-10-01T12:00:00Z"},"protected":true,"required_approvals":2,"user_can_push":true,"user_can_merge":true},{"name":"orphan","commit":null}]`)) _, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc","message":"Fix\n\nbody","timestamp":"2026-10-01T12:00:00Z"},"protected":true,"required_approvals":2,"user_can_push":true,"user_can_merge":true},{"name":"orphan","commit":null}]`))
})) }))
items, err := (Branches{Client: client}).List(context.Background(), "owner/repo", 20, 3) items, err := (Branches{Client: client}).ListPage(context.Background(), "owner/repo", 20, 3)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []Branch{ require.Equal(t, []Branch{
{Name: "main", SHA: "abc", Message: "Fix\n\nbody", UpdatedAt: "2026-10-01T12:00:00Z", Protected: true, RequiredApprovals: 2, CanPush: true, CanMerge: true}, {Name: "main", SHA: "abc", Message: "Fix\n\nbody", UpdatedAt: "2026-10-01T12:00:00Z", Protected: true, RequiredApprovals: 2, CanPush: true, CanMerge: true},
{Name: "orphan"}, {Name: "orphan"},
}, items) }, items.Items)
} }
func TestBranchCreateAndProtectPayloads(t *testing.T) { func TestBranchCreateAndProtectPayloads(t *testing.T) {
+1 -11
View File
@@ -5,7 +5,6 @@ package services
import ( import (
"context" "context"
"path"
"strconv" "strconv"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
@@ -30,19 +29,10 @@ func commentFrom(comment *gitea.Comment) Comment {
return Comment{ return Comment{
ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster), ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster),
CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), Pull: comment.PRURL != "", CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), Pull: comment.PRURL != "",
IssueNumber: discussionNumber(comment.IssueURL + comment.PRURL), IssueNumber: lastPathNumber(comment.IssueURL + comment.PRURL),
} }
} }
// discussionNumber reads the number from the web URL of an issue or pull request, or returns 0.
func discussionNumber(url string) int64 {
n, err := strconv.ParseInt(path.Base(url), 10, 64)
if err != nil {
return 0
}
return n
}
// Comments implements discussion comments under their owning repository resource. // Comments implements discussion comments under their owning repository resource.
type Comments struct { type Comments struct {
Client *transport.Client Client *transport.Client
+5
View File
@@ -66,3 +66,8 @@ func issueFrom(issue *gitea.Issue) Issue {
Assignees: convertAll(issue.Assignees, login), Milestone: issueMilestoneFrom(issue.Milestone), Comments: int64(issue.Comments), Assignees: convertAll(issue.Assignees, login), Milestone: issueMilestoneFrom(issue.Milestone), Comments: int64(issue.Comments),
} }
} }
// convertPage maps a page of SDK items onto tea results.
func convertPage[S, T any](page Page[*S], convert func(*S) T) Page[T] {
return Page[T]{Items: convertAll(page.Items, convert), More: page.More, Total: page.Total, Counted: page.Counted}
}
+6
View File
@@ -194,6 +194,12 @@ func Classify(err error, kind, key, scope string) error {
return err return err
} }
// IsNotFound reports whether err is a 404 response.
func IsNotFound(err error) bool {
failure, ok := errors.AsType[*transport.APIError](err)
return ok && failure.Status == http.StatusNotFound
}
// refusal returns the conflict reason of a refused request, or "". // refusal returns the conflict reason of a refused request, or "".
func refusal(apiErr *transport.APIError) string { func refusal(apiErr *transport.APIError) string {
if apiErr.Status == http.StatusLocked { if apiErr.Status == http.StatusLocked {
+1 -4
View File
@@ -5,11 +5,8 @@ package services
import ( import (
"context" "context"
"errors"
"net/http"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
) )
// IssueReference identifies an issue that may live in another repository. // IssueReference identifies an issue that may live in another repository.
@@ -57,7 +54,7 @@ func (s Issues) Dependencies(ctx context.Context, number int64) (IssueDependenci
}) })
return convertAll(issues, issueReferenceFrom), err return convertAll(issues, issueReferenceFrom), err
}) })
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound { if IsNotFound(err) {
return IssueDependencies{}, nil return IssueDependencies{}, nil
} }
if err != nil { if err != nil {
+1 -1
View File
@@ -132,7 +132,7 @@ func (s Issues) ListPage(ctx context.Context, opts ListOptions) (Page[Issue], er
if err != nil { if err != nil {
return Page[Issue]{}, err return Page[Issue]{}, err
} }
return Page[Issue]{Items: convertAll(issues.Items, discussionFrom), More: issues.More, Total: issues.Total, Counted: issues.Counted}, nil return convertPage(issues, discussionFrom), nil
} }
// filterValues splits a comma-separated filter, which the SDK joins again. // filterValues splits a comma-separated filter, which the SDK joins again.
+2 -2
View File
@@ -58,7 +58,7 @@ func (s Labels) ListPage(ctx context.Context, limit, page int) (Page[Label], err
labels, resp, err := s.list(ctx, limit, page) labels, resp, err := s.list(ctx, limit, page)
return labels, headerOf(resp), err return labels, headerOf(resp), err
}) })
return Page[Label]{Items: convertAll(labels.Items, labelFrom), More: labels.More, Total: labels.Total, Counted: labels.Counted}, err return convertPage(labels, labelFrom), err
} }
func (s Labels) list(ctx context.Context, limit, page int) ([]*gitea.Label, *gitea.Response, error) { func (s Labels) list(ctx context.Context, limit, page int) ([]*gitea.Label, *gitea.Response, error) {
@@ -218,7 +218,7 @@ func (s Milestones) ListPage(ctx context.Context, state string, limit, page int)
}) })
return milestones, headerOf(resp), err return milestones, headerOf(resp), err
}) })
return Page[Milestone]{Items: convertAll(milestones.Items, milestoneFrom), More: milestones.More, Total: milestones.Total, Counted: milestones.Counted}, err return convertPage(milestones, milestoneFrom), err
} }
// Create creates one milestone. // Create creates one milestone.
-27
View File
@@ -5,36 +5,9 @@ package services
import ( import (
"context" "context"
"fmt"
"strconv" "strconv"
"strings"
) )
// ResolveName selects an exact title across open and closed milestones.
func (s Milestones) ResolveName(ctx context.Context, name string) (int64, error) {
if strings.TrimSpace(name) == "" {
return 0, fmt.Errorf("milestone name cannot be empty")
}
items, err := s.All(ctx)
if err != nil {
return 0, err
}
var id int64
for _, item := range items {
if item.Title != name {
continue
}
if id != 0 {
return 0, fmt.Errorf("milestone name %q is ambiguous; select its numeric ID", name)
}
id = item.ID
}
if id == 0 {
return 0, fmt.Errorf("milestone %q does not exist; use tea milestone list --state all", name)
}
return id, nil
}
// All returns the open and closed milestones of the repository. // All returns the open and closed milestones of the repository.
func (s Milestones) All(ctx context.Context) ([]Milestone, error) { func (s Milestones) All(ctx context.Context) ([]Milestone, error) {
return listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) }) return listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) })
@@ -32,12 +32,12 @@ func TestMilestoneNamesIncludeClosedAndRejectAmbiguity(t *testing.T) {
} }
})) }))
service := Milestones{Client: client, Repository: "alice/project"} service := Milestones{Client: client, Repository: "alice/project"}
id, err := service.ResolveName(context.Background(), "Release") item, err := service.FindOne(context.Background(), "Release", false)
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, 2, id) require.EqualValues(t, 2, item.ID)
_, err = service.ResolveName(context.Background(), "missing") _, err = service.FindOne(context.Background(), "missing", false)
require.ErrorContains(t, err, "does not exist") require.ErrorContains(t, err, "not found")
duplicate = true duplicate = true
_, err = service.ResolveName(context.Background(), "Release") _, err = service.FindOne(context.Background(), "Release", false)
require.ErrorContains(t, err, "ambiguous") require.ErrorContains(t, err, "ambiguous")
} }
+2 -13
View File
@@ -34,7 +34,7 @@ func organization(ctx context.Context, client *transport.Client, owner string) (
org, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { org, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) {
return c.Organizations.GetOrg(ctx, owner) return c.Organizations.GetOrg(ctx, owner)
}) })
if statusNotFound(err) { if IsNotFound(err) {
return 0, false, nil return 0, false, nil
} }
if err != nil { if err != nil {
@@ -46,12 +46,6 @@ func organization(ctx context.Context, client *transport.Client, owner string) (
return org.ID, true, nil return org.ID, true, nil
} }
// statusNotFound reports whether err is a 404 response.
func statusNotFound(err error) bool {
failure, ok := errors.AsType[*transport.APIError](err)
return ok && failure.Status == http.StatusNotFound
}
// OrganizationOptions permits explicit empty and false preference updates. // OrganizationOptions permits explicit empty and false preference updates.
type OrganizationOptions struct { type OrganizationOptions struct {
Name *string `json:"username,omitempty"` Name *string `json:"username,omitempty"`
@@ -84,11 +78,6 @@ func (s Organizations) List(ctx context.Context, mine bool, username string, lim
return convertPage(orgs, organizationFrom), err return convertPage(orgs, organizationFrom), err
} }
// convertPage maps a page of SDK items onto tea results.
func convertPage[S, T any](page Page[*S], convert func(*S) T) Page[T] {
return Page[T]{Items: convertAll(page.Items, convert), More: page.More, Total: page.Total, Counted: page.Counted}
}
// View retrieves a named organization. // View retrieves a named organization.
func (s Organizations) View(ctx context.Context, name string) (Organization, error) { func (s Organizations) View(ctx context.Context, name string) (Organization, error) {
org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) { org, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Organization, *gitea.Response, error) {
@@ -285,7 +274,7 @@ func (s Organizations) IsPublicMember(ctx context.Context, name, username string
// check runs a membership check that answers no with a 404. // check runs a membership check that answers no with a 404.
func (s Organizations) check(ctx context.Context, op func(context.Context, *gitea.Client) (*gitea.Response, error)) (bool, error) { func (s Organizations) check(ctx context.Context, op func(context.Context, *gitea.Client) (*gitea.Response, error)) (bool, error) {
err := do(ctx, s.Client, op) err := do(ctx, s.Client, op)
if statusNotFound(err) { if IsNotFound(err) {
return false, nil return false, nil
} }
return err == nil, err return err == nil, err
+2 -2
View File
@@ -224,7 +224,7 @@ func (s Pulls) listPage(ctx context.Context, options PullListOptions) (Page[Pull
if err != nil { if err != nil {
return Page[Pull]{}, err return Page[Pull]{}, err
} }
return Page[Pull]{Items: convertAll(page.Items, pullFrom), More: page.More, Total: page.Total, Counted: page.Counted}, nil return convertPage(page, pullFrom), nil
} }
// View retrieves one pull request. // View retrieves one pull request.
@@ -476,7 +476,7 @@ func (s Pulls) Template(ctx context.Context, ref string) (string, error) {
return c.Repositories.GetFileReader(ctx, owner, name, ref, path) return c.Repositories.GetFileReader(ctx, owner, name, ref, path)
}) })
if err != nil { if err != nil {
if apiError, ok := errors.AsType[*transport.APIError](err); ok && apiError.Status == http.StatusNotFound { if IsNotFound(err) {
continue continue
} }
return "", err return "", err
+2 -8
View File
@@ -55,13 +55,7 @@ type Releases struct {
func (s Releases) endpoint() string { return "repos/" + s.Repository + "/releases" } func (s Releases) endpoint() string { return "repos/" + s.Repository + "/releases" }
// List returns a non-nil page of releases. // ListPage lists a page of releases, with whether more follow and how many there are.
func (s Releases) List(ctx context.Context, limit, page int) ([]Release, error) {
window, err := s.ListPage(ctx, limit, page)
return window.Items, err
}
// ListPage is List that also reports whether more releases follow and how many there are.
func (s Releases) ListPage(ctx context.Context, limit, page int) (Page[Release], error) { func (s Releases) ListPage(ctx context.Context, limit, page int) (Page[Release], error) {
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Release, http.Header, error) { window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Release, http.Header, error) {
var headers http.Header var headers http.Header
@@ -75,7 +69,7 @@ func (s Releases) ListPage(ctx context.Context, limit, page int) (Page[Release],
if err != nil { if err != nil {
return Page[Release]{}, err return Page[Release]{}, err
} }
return Page[Release]{Items: convertAll(window.Items, releaseFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil return convertPage(window, releaseFrom), nil
} }
// View retrieves a release by its tag, including numeric tags. // View retrieves a release by its tag, including numeric tags.
+2 -2
View File
@@ -171,9 +171,9 @@ func TestReleaseListAndViewEscapeTags(t *testing.T) {
_, _ = w.Write([]byte(`{"id":2,"tag_name":"v1/beta one"}`)) _, _ = w.Write([]byte(`{"id":2,"tag_name":"v1/beta one"}`))
})) }))
service := Releases{Client: client, Repository: "alice/project"} service := Releases{Client: client, Repository: "alice/project"}
items, err := service.List(context.Background(), 10, 2) items, err := service.ListPage(context.Background(), 10, 2)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []Release{{ID: 1, Tag: "v1", Assets: []ReleaseAsset{}}}, items) require.Equal(t, []Release{{ID: 1, Tag: "v1", Assets: []ReleaseAsset{}}}, items.Items)
item, err := service.View(context.Background(), "v1/beta one") item, err := service.View(context.Background(), "v1/beta one")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "v1/beta one", item.Tag) require.Equal(t, "v1/beta one", item.Tag)
+1 -1
View File
@@ -109,7 +109,7 @@ func (s Repositories) Readme(ctx context.Context, repo, ref string) (name, conte
return c.Repositories.ListContents(ctx, owner, name, ref, "") return c.Repositories.ListContents(ctx, owner, name, ref, "")
}) })
if err != nil { if err != nil {
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound { if IsNotFound(err) {
return "", "", nil return "", "", nil
} }
return "", "", err return "", "", err
+1 -1
View File
@@ -136,5 +136,5 @@ func (s Repositories) Search(ctx context.Context, opts RepositorySearchOptions)
if err != nil { if err != nil {
return Page[Repository]{}, err return Page[Repository]{}, err
} }
return Page[Repository]{Items: convertAll(window.Items, repositoryFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil return convertPage(window, repositoryFrom), nil
} }
+2 -10
View File
@@ -6,9 +6,7 @@ package services
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"net/http"
"strconv" "strconv"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
@@ -81,17 +79,11 @@ func (s Wiki) page(ctx context.Context, op repoOperation[*gitea.WikiPage]) (Wiki
return wikiContentFrom(page) return wikiContentFrom(page)
} }
// List returns a non-nil page of wiki metadata.
func (s Wiki) List(ctx context.Context, limit, page int) ([]WikiPage, error) {
window, err := s.ListPage(ctx, limit, page)
return window.Items, err
}
// wikiPageSize is the page size for reading the whole wiki; Gitea replaces a limit of 1 with its // wikiPageSize is the page size for reading the whole wiki; Gitea replaces a limit of 1 with its
// default page size. // default page size.
const wikiPageSize = 50 const wikiPageSize = 50
// ListPage is List that also reports whether more pages follow and how many there are. It reads // ListPage lists a page of wiki pages, with whether more follow and how many there are. It reads
// the whole page list and pages it on the client, because the server pages it inconsistently. // the whole page list and pages it on the client, because the server pages it inconsistently.
func (s Wiki) ListPage(ctx context.Context, limit, page int) (Page[WikiPage], error) { func (s Wiki) ListPage(ctx context.Context, limit, page int) (Page[WikiPage], error) {
all, err := listAll(func(page int) ([]*gitea.WikiPageMetaData, error) { all, err := listAll(func(page int) ([]*gitea.WikiPageMetaData, error) {
@@ -100,7 +92,7 @@ func (s Wiki) ListPage(ctx context.Context, limit, page int) (Page[WikiPage], er
}) })
}) })
if err != nil { if err != nil {
if apiError, ok := errors.AsType[*transport.APIError](err); !ok || apiError.Status != http.StatusNotFound { if !IsNotFound(err) {
return Page[WikiPage]{}, err return Page[WikiPage]{}, err
} }
// An uninitialized wiki returns 404. Verify repository visibility and // An uninitialized wiki returns 404. Verify repository visibility and
+5 -5
View File
@@ -59,14 +59,14 @@ func TestWikiEmptyListDoesNotHideRepositoryErrors(t *testing.T) {
} }
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
})) }))
items, err := (Wiki{Client: client, Repository: "alice/project"}).List(context.Background(), 30, 1) items, err := (Wiki{Client: client, Repository: "alice/project"}).ListPage(context.Background(), 30, 1)
if !wikiEnabled { if !wikiEnabled {
require.Error(t, err) require.Error(t, err)
return return
} }
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, items) require.NotNil(t, items.Items)
require.Empty(t, items) require.Empty(t, items.Items)
}) })
} }
} }
@@ -95,9 +95,9 @@ func TestWikiListAndRevisionsMapCommits(t *testing.T) {
{Title: "Other"}, {Title: "Other"},
{Title: "Home", URL: "https://gitea.test/wiki/Home", SHA: "abc", Author: "Alice", UpdatedAt: "2026-10-04T00:00:00Z"}, {Title: "Home", URL: "https://gitea.test/wiki/Home", SHA: "abc", Author: "Alice", UpdatedAt: "2026-10-04T00:00:00Z"},
}, More: true, Total: 3, Counted: true}, pages) }, More: true, Total: 3, Counted: true}, pages)
last, err := service.List(context.Background(), 2, 2) last, err := service.ListPage(context.Background(), 2, 2)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []WikiPage{{Title: "Draft"}}, last) require.Equal(t, []WikiPage{{Title: "Draft"}}, last.Items)
revisions, err := service.Revisions(context.Background(), "Home", 3) revisions, err := service.Revisions(context.Background(), "Home", 3)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []WikiRevision{{SHA: "abc", Message: "Edit", Author: "Alice", CreatedAt: "2026-10-04T00:00:00Z"}, {SHA: "def"}}, revisions) require.Equal(t, []WikiRevision{{SHA: "abc", Message: "Edit", Author: "Alice", CreatedAt: "2026-10-04T00:00:00Z"}, {SHA: "def"}}, revisions)
+3
View File
@@ -83,6 +83,9 @@ func (c Cell) style(text string, d Display, link bool) string {
return Hyperlink(c.Link, text, link && d.Links) return Hyperlink(c.Link, text, link && d.Links)
} }
// Render styles the cell's text outside a table: its color, muted or bold, without a link.
func (c Cell) Render(d Display) string { return c.style(SafeLine(c.Text), d, false) }
// Table renders rows under columns; rows may have more cells than there are columns. On a TTY it // Table renders rows under columns; rows may have more cells than there are columns. On a TTY it
// prints an uppercase header row when any column has a header, aligns columns two spaces apart, // prints an uppercase header row when any column has a header, aligns columns two spaces apart,
// and shrinks Flex columns until rows fit the display width. Otherwise it writes one line of // and shrinks Flex columns until rows fit the display width. Otherwise it writes one line of