diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 302ca7f7..be8633d3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -494,12 +494,6 @@ const ( time delete time list time reset - wiki create - wiki delete - wiki edit - wiki list - wiki revisions - wiki view ` longsToWrite = ` issue @@ -512,6 +506,5 @@ const ( pr attachment pr comment time - wiki ` ) diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 5cca1e52..a575841b 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -240,7 +240,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"webhook", "delete", "not-an-id"}, "invalid resource number \"not-an-id\""}, {[]string{"webhook", "list", "--org", "organization", "--account"}, "--org, --account, --global, and --repo select mutually exclusive scopes"}, {[]string{"webhook", "list", "--global", "-R", "owner/repo"}, "--org, --account, --global, and --repo select mutually exclusive scopes"}, - {[]string{"wiki", "create", "--body-file", "-"}, "--body-file - requires --title"}, + {[]string{"wiki", "create", "--body-file", "-"}, "--body-file - requires a title"}, {[]string{"wiki", "create", "--title", "Home", "--body", "text", "--json=created_at"}, "unknown JSON field \"created_at\""}, {[]string{"wiki", "edit", "Home", "--body="}, "wiki content cannot be empty"}, {[]string{"wiki", "revisions", "Home", "--page", "0"}, `"--page" flag: must be a positive integer`}, @@ -258,7 +258,6 @@ func TestConfirmationsNameResolvedValues(t *testing.T) { inputs map[string]string want string }{ - {[]string{"wiki", "create"}, map[string]string{"title": "Home", "body": "text"}, "create wiki page Home on alice/project"}, {[]string{"release", "create"}, map[string]string{"Tag": "v1.0.0"}, "Create release v1.0.0 in alice/project?"}, {[]string{"time", "delete", "5", "7"}, nil, "delete tracked time entry 7 on alice/project #5"}, } { diff --git a/internal/app/wiki.go b/internal/app/wiki.go index a94eb795..f99e50ca 100644 --- a/internal/app/wiki.go +++ b/internal/app/wiki.go @@ -4,8 +4,10 @@ package app import ( + "context" "errors" "math" + "strconv" "strings" "gitea.dev/tea/internal/clierr" @@ -14,146 +16,319 @@ import ( "github.com/spf13/cobra" ) -func wikiRecord(item services.WikiPage) terminal.Record { - return terminal.Record{Heading: []terminal.Cell{{Text: item.Title, Bold: true}}, Lines: []string{item.Body, item.URL}} +func (a *application) wikiCommand() *cobra.Command { + group := &cobra.Command{ + Use: "wiki", + Short: "Manage repository wiki pages", + Long: "List, view, create, edit and delete the pages of a repository's wiki, and list their revisions.", + } + group.AddCommand(a.wikiListCommand(), a.wikiViewCommand(), a.wikiRevisionsCommand(), a.wikiCreateCommand(), a.wikiEditCommand(), a.wikiDeleteCommand()) + return group } -func (a *application) wikiCommand() *cobra.Command { - group := &cobra.Command{Use: "wiki", Short: "Manage repository wiki pages"} +// completeWikiPages completes the titles of the repository's wiki pages. +func (a *application) completeWikiPages() cobra.CompletionFunc { + 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) + return describe(pages, func(page services.WikiPage) (string, string) { return page.Title, "" }), err + }) +} + +func (a *application) wikiListCommand() *cobra.Command { var limit, page int - list := &cobra.Command{Use: "list", Short: "List wiki pages", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - items, err := (services.Wiki{Client: target.client, Repository: target.Repo}).List(cmd.Context(), limit, page) - if err != nil { - return err - } - return writeList(a, cmd, items, func(item services.WikiPage) []terminal.Cell { - return terminal.Row(item.Title, item.Author, item.UpdatedAt) - }) - }} - pageFlags(list, &limit, &page) - group.AddCommand(list) - var revisionPage int - revisions := &cobra.Command{Use: "revisions ", Short: "List revisions of a wiki page", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - items, err := (services.Wiki{Client: target.client, Repository: target.Repo}).Revisions(cmd.Context(), args[0], revisionPage) - if err != nil { - return err - } - return writeList(a, cmd, items, func(item services.WikiRevision) []terminal.Cell { - return terminal.Row(item.SHA, item.Author, item.CreatedAt, item.Message) - }) - }} - positiveIntVar(revisions, &revisionPage, "page", 1, math.MaxInt, "Revision page (server controls page size)") - group.AddCommand(revisions) - for _, verb := range []string{"view", "create", "edit", "delete"} { - var title, body, file, message string - var useEditor bool - command := &cobra.Command{Use: verb + " ", Short: titleCase(verb) + " a wiki page", Args: argsExact(1)} - switch verb { - case "create": - command.Use = verb - command.Args = argsExact(0) - case "delete": - command.Annotations = textOutput("wiki page deletion does not return a resource") - } - if verb == "create" || verb == "edit" { - command.Flags().StringVar(&title, "title", "", "Page title") - command.Flags().BoolVar(&useEditor, "editor", false, "Edit page content in an editor") - command.Flags().StringVar(&body, "body", "", "Page content") - command.Flags().StringVar(&file, "body-file", "", "Read content from file, or - for stdin") - command.Flags().StringVar(&message, "message", "", "Wiki commit message") - } - command.RunE = func(cmd *cobra.Command, args []string) (runErr error) { - var editorDraft terminal.Draft - defer finishDraft(&editorDraft, &runErr) + command := &cobra.Command{ + Use: "list", + Short: "List wiki pages", + Example: "$ tea wiki list\n$ tea wiki list -R alice/project --json title,url", + Args: argsExact(0), + RunE: func(cmd *cobra.Command, _ []string) error { + target, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + result, err := (services.Wiki{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), limit, page) + if err != nil { + return err + } + spec := listSpec[services.WikiPage]{ + Columns: []terminal.Column{{Header: "Title", Flex: true}, {Header: "Author"}, {Header: "Updated"}, {Header: "URL", PipedOnly: true}}, + Row: func(item services.WikiPage) []terminal.Cell { + return []terminal.Cell{{Text: item.Title, Link: item.URL}, {Text: item.Author}, {Time: terminal.ParseTime(item.UpdatedAt)}, {Text: item.URL}} + }, + Context: "wiki pages in " + target.Repo, Empty: "no wiki pages in " + target.Repo, + } + return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted}) + }, + } + pageFlags(command, &limit, &page) + return command +} + +func (a *application) wikiViewCommand() *cobra.Command { + var web bool + command := &cobra.Command{ + Use: "view ", + Short: "View a wiki page", + Long: "View a wiki page, rendered on a terminal.", + Example: "$ tea wiki view Home\n$ tea wiki view \"Getting Started\" --web", + Args: argsExact(1), + RunE: func(cmd *cobra.Command, args []string) error { + target, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + item, err := (services.Wiki{Client: target.client, Repository: target.Repo}).View(cmd.Context(), args[0]) + if err != nil { + return err + } + if web { + return a.openBrowser(cmd, item.URL) + } + return writeView(a, cmd, item, a.wikiView) + }, + } + command.Flags().BoolVarP(&web, "web", "w", false, "Open the page in the browser") + command.ValidArgsFunction = a.completeWikiPages() + return command +} + +// wikiView shows a wiki page with its content as the body. +func (a *application) wikiView(item services.WikiPage) terminal.View { + var meta []string + if item.Author != "" { + meta = append(meta, item.Author) + } + if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() { + meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated)) + } + if item.CommitCount > 0 { + meta = append(meta, countNoun(int(item.CommitCount), "revision")) + } + return terminal.View{ + Title: item.Title, Meta: strings.Join(meta, " · "), Body: item.Body, Kind: "wiki page", URL: item.URL, + Fields: []terminal.Field{{Label: "Author", Value: item.Author, PipedOnly: true}, {Label: "Updated", Value: item.UpdatedAt, PipedOnly: true}}, + } +} + +func (a *application) wikiRevisionsCommand() *cobra.Command { + var page int + command := &cobra.Command{ + Use: "revisions ", + Short: "List revisions of a wiki page", + Example: "$ tea wiki revisions Home", + Args: argsExact(1), + RunE: func(cmd *cobra.Command, args []string) error { + target, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + items, err := (services.Wiki{Client: target.client, Repository: target.Repo}).Revisions(cmd.Context(), args[0], page) + if err != nil { + return err + } + spec := listSpec[services.WikiRevision]{ + Columns: []terminal.Column{{Header: "Commit"}, {Header: "Author"}, {Header: "Date"}, {Header: "Message", Flex: true}}, + Row: func(item services.WikiRevision) []terminal.Cell { + message, _, _ := strings.Cut(item.Message, "\n") + sha := item.SHA + if a.deps.Streams.OutTTY { + sha = shortSHA(sha) + } + return []terminal.Cell{{Text: sha, Muted: true}, {Text: item.Author}, {Time: terminal.ParseTime(item.CreatedAt)}, {Text: message}} + }, + Context: "revisions of wiki page " + strconv.Quote(args[0]), Empty: "no revisions of wiki page " + strconv.Quote(args[0]), + } + return writeTable(a, cmd, items, spec, listPage{Number: page, Counted: false, More: false}) + }, + } + positiveIntVar(command, &page, "page", 1, math.MaxInt, "Revision page (server controls page size)") + command.ValidArgsFunction = a.completeWikiPages() + return command +} + +// wikiContent are the flags of wiki create and wiki edit. +type wikiContent struct { + title, body, file, message string + editor bool +} + +func (c *wikiContent) register(cmd *cobra.Command) { + flags := cmd.Flags() + flags.StringVarP(&c.title, "title", "t", "", "Page title") + flags.BoolVarP(&c.editor, "editor", "e", false, "Write the page content in an editor") + flags.StringVarP(&c.body, "body", "b", "", "Page content") + flags.StringVarP(&c.file, "body-file", "F", "", "Read the page content from a file, or - for standard input") + flags.StringVarP(&c.message, "message", "m", "", "Commit message of the change") +} + +func (a *application) wikiCreateCommand() *cobra.Command { + var content wikiContent + command := &cobra.Command{ + Use: "create []", + Short: "Create a wiki page", + Long: "Create a wiki page. Without --body or --body-file, the content is written in an editor or typed on the terminal.", + Example: `$ tea wiki create "Getting Started" --body-file docs/start.md +$ tea wiki create Home --editor`, + Args: usageArgs(cobra.MaximumNArgs(1)), + RunE: func(cmd *cobra.Command, args []string) (runErr error) { + var draft terminal.Draft + defer finishDraft(&draft, &runErr) + if len(args) > 0 { + if cmd.Flags().Changed("title") { + return clierr.Wrap(clierr.Usage, errors.New("supply the title as an argument or --title, not both")) + } + content.title = args[0] + } if err := validateBodySources(cmd); err != nil { return err } - prompted := false - if verb == "create" || verb == "edit" { - if file == "-" && verb == "create" && title == "" { - return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt")) - } - var err error - if body, err = a.readBody(cmd, body, file); err != nil { + if content.file == "-" && content.title == "" { + return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires a title; stdin cannot also supply a prompt")) + } + body, err := a.readBody(cmd, content.body, content.file) + if err != nil { + return err + } + if _, err := a.requireInput(cmd, &content.title, "title", "Page title", nil); err != nil { + return err + } + if strings.TrimSpace(content.title) == "" { + return clierr.Wrap(clierr.Usage, errors.New("wiki title cannot be empty")) + } + target, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + if !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") { + if draft, err = a.wikiBody(cmd, content.editor, ""); err != nil { return err } - if verb == "create" { - prompted, err = a.require(cmd, &title, "title") - if err != nil { - return err - } - } - if !useEditor && !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") { - asked, err := a.require(cmd, &body, "body") - if err != nil { - return err - } - prompted = prompted || asked - } - if !useEditor && strings.TrimSpace(body) == "" { - return clierr.Wrap(clierr.Usage, errors.New("wiki content cannot be empty")) - } - if (verb == "create" || cmd.Flags().Changed("title")) && strings.TrimSpace(title) == "" { - return clierr.Wrap(clierr.Usage, errors.New("wiki title cannot be empty")) - } + body = draft.Body + } + if strings.TrimSpace(body) == "" { + return clierr.Wrap(clierr.Usage, errors.New("wiki content cannot be empty")) + } + item, err := (services.Wiki{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), services.WikiOptions{Title: content.title, Body: body, Message: content.message}) + if err != nil { + return err + } + a.done(cmd, "Created wiki page %q in %s", item.Title, target.Repo) + if a.options.json != "" { + return a.writeJSON(cmd, item) + } + return a.printURL(cmd, item.URL) + }, + } + content.register(command) + return command +} + +// wikiBody collects page content: in the editor with --editor, else in the editor or an inline +// field on a terminal. Without a terminal it fails, naming --body. +func (a *application) wikiBody(cmd *cobra.Command, editor bool, initial string) (terminal.Draft, error) { + if editor { + return a.editDraft(cmd, initial) + } + s, err := a.deps.Settings.Load() + if err != nil { + return terminal.Draft{}, err + } + if !a.policy(s).CanPrompt(a.deps.Streams) { + return terminal.Draft{}, clierr.Wrap(clierr.Usage, errors.New("missing required input: supply --body, --body-file or --editor")) + } + return a.bodyEditor(cmd)(cmd.Context(), initial) +} + +func (a *application) wikiEditCommand() *cobra.Command { + var content wikiContent + command := &cobra.Command{ + Use: "edit <page>", + Short: "Edit a wiki page", + Long: "Change the title or the content of a wiki page. Renaming with --title alone keeps the content.", + Example: `$ tea wiki edit Home --body-file docs/home.md --message "Rewrite the home page" +$ tea wiki edit "Old Title" --title "New Title" +$ tea wiki edit Home --editor`, + Args: argsExact(1), + RunE: func(cmd *cobra.Command, args []string) (runErr error) { + var draft terminal.Draft + defer finishDraft(&draft, &runErr) + if err := validateBodySources(cmd); err != nil { + return err + } + changed := cmd.Flags().Changed + if !changed("title") && !changed("body") && !changed("body-file") && !content.editor { + return clierr.Wrap(clierr.Usage, errors.New("supply --title, --body, --body-file or --editor")) + } + if changed("title") && strings.TrimSpace(content.title) == "" { + return clierr.Wrap(clierr.Usage, errors.New("wiki title cannot be empty")) + } + body, err := a.readBody(cmd, content.body, content.file) + if err != nil { + return err + } + if (changed("body") || changed("body-file")) && strings.TrimSpace(body) == "" { + return clierr.Wrap(clierr.Usage, errors.New("wiki content cannot be empty")) } target, err := a.resolve(cmd.Context(), true) if err != nil { return err } service := services.Wiki{Client: target.client, Repository: target.Repo} - if useEditor { - initial := "" - if verb == "edit" { - item, err := service.View(cmd.Context(), args[0]) - if err != nil { - return err - } - initial = item.Body - } - editorDraft, err = a.editDraft(cmd, initial) + if content.editor || (!changed("body") && !changed("body-file")) { + current, err := service.View(cmd.Context(), args[0]) if err != nil { return err } - body = editorDraft.Body - if strings.TrimSpace(body) == "" { - return clierr.Wrap(clierr.Usage, errors.New("wiki content cannot be empty")) + body = current.Body + if content.editor { + if draft, err = a.editDraft(cmd, current.Body); err != nil { + return err + } + if body = draft.Body; strings.TrimSpace(body) == "" { + return clierr.Wrap(clierr.Usage, errors.New("wiki content cannot be empty")) + } } } - if verb == "delete" || prompted { - subject := title - if verb != "create" { - subject = args[0] - } - if err := a.confirm(cmd, verb+" wiki page "+subject+" on "+target.Repo); err != nil { - return err - } - } - var item services.WikiPage - switch verb { - case "view": - item, err = service.View(cmd.Context(), args[0]) - case "create": - item, err = service.Create(cmd.Context(), services.WikiOptions{Title: title, Body: body, Message: message}) - case "edit": - item, err = service.Edit(cmd.Context(), args[0], services.WikiOptions{Title: title, Body: body, Message: message}) - case "delete": - return service.Delete(cmd.Context(), args[0]) - } + item, err := service.Edit(cmd.Context(), args[0], services.WikiOptions{Title: content.title, Body: body, Message: content.message}) if err != nil { return err } - return writeRecord(a, cmd, item, wikiRecord) - } - group.AddCommand(command) + if item.Title != args[0] { + a.done(cmd, "Renamed wiki page %q to %q in %s", args[0], item.Title, target.Repo) + } else { + a.done(cmd, "Edited wiki page %q in %s", item.Title, target.Repo) + } + return a.writeMutationJSON(cmd, item) + }, } - return group + content.register(command) + command.ValidArgsFunction = a.completeWikiPages() + return command +} + +func (a *application) wikiDeleteCommand() *cobra.Command { + command := &cobra.Command{ + Use: "delete <page>", + Short: "Delete a wiki page", + Example: "$ tea wiki delete \"Old Notes\" --yes", + Annotations: map[string]string{"tea.confirm": "true", "tea.text": "wiki page deletion does not return a resource"}, + Args: argsExact(1), + RunE: func(cmd *cobra.Command, args []string) error { + target, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + subject := "wiki page " + strconv.Quote(args[0]) + " from " + target.Repo + if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + "?", NonTTY: "delete " + subject}); err != nil { + return err + } + if err := (services.Wiki{Client: target.client, Repository: target.Repo}).Delete(cmd.Context(), args[0]); err != nil { + return err + } + a.done(cmd, "Deleted %s", subject) + return nil + }, + } + command.ValidArgsFunction = a.completeWikiPages() + return command } diff --git a/internal/app/wiki_test.go b/internal/app/wiki_test.go new file mode 100644 index 00000000..9a487409 --- /dev/null +++ b/internal/app/wiki_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWikiEditRenamesAndKeepsTheContent(t *testing.T) { + var requests []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + if r.Method == http.MethodPatch { + requirePayload(t, `{"title":"New Title","content_base64":"S2VlcCBtZQ=="}`, r.Body) + _, _ = w.Write([]byte(`{"title":"New Title","content_base64":"S2VlcCBtZQ=="}`)) + return + } + _, _ = w.Write([]byte(`{"title":"Old Title","content_base64":"S2VlcCBtZQ=="}`)) + })) + defer server.Close() + deps, out, diagnostics := repoTTYDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"wiki", "edit", "Old Title", "--title", "New Title", "-R", "alice/x", "--no-input"})) + require.Equal(t, []string{"GET /api/v1/repos/alice/x/wiki/page/Old Title", "PATCH /api/v1/repos/alice/x/wiki/page/Old Title"}, requests) + require.Empty(t, out.String()) + require.Equal(t, "✓ Renamed wiki page \"Old Title\" to \"New Title\" in alice/x\n", diagnostics.String()) + err := Execute(context.Background(), deps, []string{"wiki", "edit", "Home", "-R", "alice/x"}) + require.ErrorContains(t, err, "supply --title, --body, --body-file or --editor") +} + +func TestWikiListLimitsPagesOnTheClient(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[{"title":"Getting Started","last_commit":{"author":{"name":"alice","date":"2026-10-11T18:00:00Z"}}},{"title":"Home"}]`)) + })) + defer server.Close() + deps, out, _ := repoTTYDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"wiki", "list", "--limit", "1", "-R", "alice/x"})) + require.Equal(t, `Showing 1 of 2 wiki pages in alice/x · next: --page 2 + +TITLE AUTHOR UPDATED +Getting Started alice about 2 hours ago +`, out.String()) +} + +func TestWikiViewAndCreate(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + requirePayload(t, `{"title":"Page Two","content_base64":"Ym9keQ=="}`, r.Body) + } + _, _ = w.Write([]byte(`{"title":"Page Two","html_url":"https://gitea.test/alice/x/wiki/Page-Two","content_base64":"Ym9keQ==","commit_count":3, + "last_commit":{"author":{"name":"alice","date":"2026-10-11T19:00:00Z"}}}`)) + })) + defer server.Close() + deps, out, diagnostics := repoTTYDeps(t, server.URL) + deps.Getenv = serverEnv(server.URL, map[string]string{"TERM": "dumb", "TEA_MARKDOWN": "raw"}) + require.NoError(t, Execute(context.Background(), deps, []string{"wiki", "view", "Page Two", "-R", "alice/x"})) + require.Equal(t, "Page Two\nalice · updated about 1 hour ago · 3 revisions\n\nbody\n\nView this wiki page on Gitea: https://gitea.test/alice/x/wiki/Page-Two\n", out.String()) + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"wiki", "create", "Page Two", "-b", "body", "-R", "alice/x"})) + require.Equal(t, "https://gitea.test/alice/x/wiki/Page-Two\n", out.String()) + require.Equal(t, "✓ Created wiki page \"Page Two\" in alice/x\n", diagnostics.String()) +} diff --git a/internal/services/wiki.go b/internal/services/wiki.go index be15fd6d..302d2d52 100644 --- a/internal/services/wiki.go +++ b/internal/services/wiki.go @@ -82,27 +82,41 @@ func (s Wiki) page(ctx context.Context, op repoOperation[*gitea.WikiPage]) (Wiki // List returns a non-nil page of wiki metadata. func (s Wiki) List(ctx context.Context, limit, page int) ([]WikiPage, error) { - pages, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.WikiPageMetaData, *gitea.Response, error) { - return c.Wiki.ListPages(ctx, owner, repo, gitea.ListWikiPagesOptions{ListOptions: listOptions(limit, page)}) + 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 +// default page size. +const wikiPageSize = 50 + +// ListPage is List that also reports whether more pages follow and how many there are. It reads +// 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) { + all, err := listAll(func(page int) ([]*gitea.WikiPageMetaData, error) { + return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.WikiPageMetaData, *gitea.Response, error) { + return c.Wiki.ListPages(ctx, owner, repo, gitea.ListWikiPagesOptions{ListOptions: listOptions(wikiPageSize, page)}) + }) }) if err != nil { if apiError, ok := errors.AsType[*transport.APIError](err); !ok || apiError.Status != http.StatusNotFound { - return nil, err + return Page[WikiPage]{}, err } // An uninitialized wiki returns 404. Verify repository visibility and // wiki configuration before treating that response as an empty list. repository, checkErr := (Repositories{Client: s.Client}).get(ctx, s.Repository) if checkErr != nil { - return nil, checkErr + return Page[WikiPage]{}, checkErr } if !repository.HasWiki { - return nil, err + return Page[WikiPage]{}, err } - return []WikiPage{}, nil + return Page[WikiPage]{Items: []WikiPage{}, Counted: true}, nil } - return convertAll(pages, func(item *gitea.WikiPageMetaData) WikiPage { + items, err := pageOf(convertAll(all, func(item *gitea.WikiPageMetaData) WikiPage { return wikiPageFrom(item.Title, item.HTMLURL, item.LastCommit) - }), nil + }), limit, page) + return Page[WikiPage]{Items: items, More: page*limit < len(all), Total: len(all), Counted: true}, err } // View retrieves and decodes a wiki page. diff --git a/internal/services/wiki_test.go b/internal/services/wiki_test.go index 6e3dfc9d..c7df529c 100644 --- a/internal/services/wiki_test.go +++ b/internal/services/wiki_test.go @@ -75,9 +75,12 @@ func TestWikiListAndRevisionsMapCommits(t *testing.T) { client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/repos/alice/project/wiki/pages": - assert.Equal(t, "5", r.URL.Query().Get("limit")) - assert.Equal(t, "2", r.URL.Query().Get("page")) - _, _ = w.Write([]byte(`[{"title":"Home","html_url":"https://gitea.test/wiki/Home","last_commit":{"sha":"abc","author":{"name":"Alice","email":"author@example.test","date":"2026-10-04T00:00:00Z"}}},{"title":"Draft"}]`)) + assert.Equal(t, "50", r.URL.Query().Get("limit")) + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[{"title":"Other"},{"title":"Home","html_url":"https://gitea.test/wiki/Home","last_commit":{"sha":"abc","author":{"name":"Alice","email":"author@example.test","date":"2026-10-04T00:00:00Z"}}},{"title":"Draft"}]`)) case "/api/v1/repos/alice/project/wiki/revisions/Home": assert.Equal(t, "3", r.URL.Query().Get("page")) _, _ = w.Write([]byte(`{"commits":[{"sha":"abc","message":"Edit","author":{"name":"Alice","email":"author@example.test","date":"2026-10-04T00:00:00Z"}},{"sha":"def","author":null}],"count":2}`)) @@ -86,12 +89,15 @@ func TestWikiListAndRevisionsMapCommits(t *testing.T) { } })) service := Wiki{Client: client, Repository: "alice/project"} - pages, err := service.List(context.Background(), 5, 2) + pages, err := service.ListPage(context.Background(), 2, 1) require.NoError(t, err) - require.Equal(t, []WikiPage{ + require.Equal(t, Page[WikiPage]{Items: []WikiPage{ + {Title: "Other"}, {Title: "Home", URL: "https://gitea.test/wiki/Home", SHA: "abc", Author: "Alice", UpdatedAt: "2026-10-04T00:00:00Z"}, - {Title: "Draft"}, - }, pages) + }, More: true, Total: 3, Counted: true}, pages) + last, err := service.List(context.Background(), 2, 2) + require.NoError(t, err) + require.Equal(t, []WikiPage{{Title: "Draft"}}, last) revisions, err := service.Revisions(context.Background(), "Home", 3) require.NoError(t, err) require.Equal(t, []WikiRevision{{SHA: "abc", Message: "Edit", Author: "Alice", CreatedAt: "2026-10-04T00:00:00Z"}, {SHA: "def"}}, revisions)