diff --git a/cmd/cmd.go b/cmd/cmd.go index 9bea3f39..b0506a2f 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -39,6 +39,7 @@ func App() *cli.Command { &CmdRepos, &CmdBranches, &CmdActions, + &CmdWiki, &CmdWebhooks, &CmdAddComment, diff --git a/cmd/wiki.go b/cmd/wiki.go new file mode 100644 index 00000000..4ab20d40 --- /dev/null +++ b/cmd/wiki.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + wikiCmd "gitea.dev/tea/cmd/wiki" + "github.com/urfave/cli/v3" +) + +// CmdWiki represents the wiki command. +var CmdWiki = cli.Command{ + Name: "wiki", + Category: catEntities, + Usage: "Manage repository wiki pages", + Description: "Manage repository wiki pages", + Action: runWiki, + Commands: []*cli.Command{ + &wikiCmd.CmdWikiList, + &wikiCmd.CmdWikiView, + &wikiCmd.CmdWikiRevisions, + &wikiCmd.CmdWikiCreate, + &wikiCmd.CmdWikiEdit, + &wikiCmd.CmdWikiDelete, + }, + Flags: append([]cli.Flag{}, wikiCmd.CmdWikiList.Flags...), +} + +func runWiki(_ stdctx.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) +} diff --git a/cmd/wiki/read.go b/cmd/wiki/read.go new file mode 100644 index 00000000..debaa6f0 --- /dev/null +++ b/cmd/wiki/read.go @@ -0,0 +1,139 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + stdctx "context" + "net/http" + + "gitea.dev/sdk" + "gitea.dev/tea/cmd/flags" + teaContext "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var ( + wikiFieldsFlag = flags.FieldsFlag(print.WikiPageFields, []string{"title", "author", "updated", "sha"}) + wikiRevisionFieldsFlag = flags.FieldsFlag(print.WikiRevisionFields, []string{"sha", "author", "date", "message"}) +) + +// CmdWikiList represents the list subcommand for wiki pages. +var CmdWikiList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List wiki pages of the repository", + Description: "List wiki pages of the repository", + ArgsUsage: " ", + Action: RunWikiList, + Flags: append([]cli.Flag{ + wikiFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// CmdWikiView represents the view subcommand for wiki pages. +var CmdWikiView = cli.Command{ + Name: "view", + Usage: "View a wiki page", + Description: "View a wiki page", + ArgsUsage: "", + Action: RunWikiView, + Flags: append([]cli.Flag{}, flags.AllDefaultFlags...), +} + +// CmdWikiRevisions represents the revisions subcommand for wiki pages. +var CmdWikiRevisions = cli.Command{ + Name: "revisions", + Aliases: []string{"history"}, + Usage: "List revisions of a wiki page", + Description: "List revisions of a wiki page", + ArgsUsage: "", + Action: RunWikiRevisions, + Flags: append([]cli.Flag{ + wikiRevisionFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +type wikiReadClient interface { + ListPages(ctx stdctx.Context, owner, repo string, opt gitea.ListWikiPagesOptions) ([]*gitea.WikiPageMetaData, *gitea.Response, error) + GetPage(ctx stdctx.Context, owner, repo, pageName string) (*gitea.WikiPage, *gitea.Response, error) + GetPageRevisions(ctx stdctx.Context, owner, repo, pageName string, opt gitea.ListWikiPageRevisionsOptions) (*gitea.WikiCommitList, *gitea.Response, error) +} + +// RunWikiList lists wiki pages. +func RunWikiList(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := initWikiContext(cmd) + if err != nil { + return err + } + fields, err := wikiFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + return runWikiListWithClient(ctx, ctx.Login.Client().Wiki, fields) +} + +// RunWikiView shows a wiki page. +func RunWikiView(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := initWikiContext(cmd) + if err != nil { + return err + } + return runWikiViewWithClient(ctx, cmd.Args().First(), ctx.Login.Client().Wiki) +} + +// RunWikiRevisions lists revisions of a wiki page. +func RunWikiRevisions(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := initWikiContext(cmd) + if err != nil { + return err + } + fields, err := wikiRevisionFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + return runWikiRevisionsWithClient(ctx, cmd.Args().First(), ctx.Login.Client().Wiki, fields) +} + +func initWikiContext(cmd *cli.Command) (*teaContext.TeaContext, error) { + ctx, err := teaContext.InitCommand(cmd) + if err != nil { + return nil, err + } + if err := ctx.Ensure(teaContext.CtxRequirement{RemoteRepo: true}); err != nil { + return nil, err + } + return ctx, nil +} + +func runWikiListWithClient(ctx *teaContext.TeaContext, client wikiReadClient, fields []string) error { + pages, resp, err := client.ListPages(stdctx.Background(), ctx.Owner, ctx.Repo, gitea.ListWikiPagesOptions{ListOptions: flags.GetListOptions(ctx.Command)}) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return print.WikiPagesList(nil, ctx.Output, fields) + } + return err + } + return print.WikiPagesList(pages, ctx.Output, fields) +} + +func runWikiViewWithClient(ctx *teaContext.TeaContext, pageName string, client wikiReadClient) error { + page, _, err := client.GetPage(stdctx.Background(), ctx.Owner, ctx.Repo, pageName) + if err != nil { + return err + } + return print.WikiPageDetails(page, ctx.Output) +} + +func runWikiRevisionsWithClient(ctx *teaContext.TeaContext, pageName string, client wikiReadClient, fields []string) error { + revisions, _, err := client.GetPageRevisions(stdctx.Background(), ctx.Owner, ctx.Repo, pageName, gitea.ListWikiPageRevisionsOptions{Page: ctx.Int("page")}) + if err != nil { + return err + } + return print.WikiRevisionsList(revisions.WikiCommits, ctx.Output, fields) +} diff --git a/cmd/wiki/read_test.go b/cmd/wiki/read_test.go new file mode 100644 index 00000000..998ebb76 --- /dev/null +++ b/cmd/wiki/read_test.go @@ -0,0 +1,133 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "bytes" + stdctx "context" + "errors" + "net/http" + "testing" + + "gitea.dev/sdk" + "gitea.dev/tea/cmd/flags" + teaContext "gitea.dev/tea/modules/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +type fakeWikiReadClient struct { + owner string + repo string + pageName string + listOpts gitea.ListWikiPagesOptions + revisionOpts gitea.ListWikiPageRevisionsOptions + pages []*gitea.WikiPageMetaData + page *gitea.WikiPage + revisions *gitea.WikiCommitList + listResp *gitea.Response + listErr error +} + +func (f *fakeWikiReadClient) ListPages(_ stdctx.Context, owner, repo string, opt gitea.ListWikiPagesOptions) ([]*gitea.WikiPageMetaData, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.listOpts = opt + return f.pages, f.listResp, f.listErr +} + +func (f *fakeWikiReadClient) GetPage(_ stdctx.Context, owner, repo, pageName string) (*gitea.WikiPage, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.pageName = pageName + return f.page, nil, nil +} + +func (f *fakeWikiReadClient) GetPageRevisions(_ stdctx.Context, owner, repo, pageName string, opt gitea.ListWikiPageRevisionsOptions) (*gitea.WikiCommitList, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.pageName = pageName + f.revisionOpts = opt + return f.revisions, nil, nil +} + +func newWikiTestContext(output string) *teaContext.TeaContext { + cmd := &cli.Command{ + Flags: []cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + &flags.OutputFlag, + }, + } + cmd.Writer = &bytes.Buffer{} + requireNoError := func(err error) { + if err != nil { + panic(err) + } + } + requireNoError(cmd.Set("output", output)) + return &teaContext.TeaContext{ + Command: cmd, + Owner: "octo", + Repo: "tea", + Output: output, + } +} + +func TestRunWikiListWithClientUsesRepoAndPagination(t *testing.T) { + ctx := newWikiTestContext("json") + require.NoError(t, ctx.Set("page", "2")) + require.NoError(t, ctx.Set("limit", "5")) + + client := &fakeWikiReadClient{ + pages: []*gitea.WikiPageMetaData{{Title: "Home"}}, + } + + err := runWikiListWithClient(ctx, client, []string{"title"}) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, 2, client.listOpts.Page) + assert.Equal(t, 5, client.listOpts.PageSize) +} + +func TestRunWikiListWithClientTreats404AsEmptyWiki(t *testing.T) { + ctx := newWikiTestContext("json") + client := &fakeWikiReadClient{ + listResp: &gitea.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, + listErr: errors.New("not found"), + } + + err := runWikiListWithClient(ctx, client, []string{"title"}) + require.NoError(t, err) +} + +func TestRunWikiViewWithClientUsesRequestedPage(t *testing.T) { + ctx := newWikiTestContext("json") + client := &fakeWikiReadClient{ + page: &gitea.WikiPage{Title: "Home", ContentBase64: "IyBIZWxsbw=="}, + } + + err := runWikiViewWithClient(ctx, "Home", client) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.pageName) +} + +func TestRunWikiRevisionsWithClientUsesRequestedPageAndPageFlag(t *testing.T) { + ctx := newWikiTestContext("json") + require.NoError(t, ctx.Set("page", "3")) + client := &fakeWikiReadClient{ + revisions: &gitea.WikiCommitList{WikiCommits: []*gitea.WikiCommit{}}, + } + + err := runWikiRevisionsWithClient(ctx, "Home", client, []string{"sha"}) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.pageName) + assert.Equal(t, 3, client.revisionOpts.Page) +} diff --git a/cmd/wiki/write.go b/cmd/wiki/write.go new file mode 100644 index 00000000..f55a6176 --- /dev/null +++ b/cmd/wiki/write.go @@ -0,0 +1,170 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "encoding/base64" + "fmt" + + stdctx "context" + + "gitea.dev/sdk" + "gitea.dev/tea/cmd/flags" + teaContext "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + "github.com/urfave/cli/v3" +) + +var wikiTitleFlag = &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "wiki page title", +} + +var wikiContentFlag = &cli.StringFlag{ + Name: "content", + Aliases: []string{"c"}, + Usage: "wiki page content", +} + +var wikiMessageFlag = &cli.StringFlag{ + Name: "message", + Aliases: []string{"m"}, + Usage: "commit message for the wiki change", +} + +var wikiDeleteConfirmFlag = &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", +} + +var wikiWriteFlags = append([]cli.Flag{wikiTitleFlag, wikiContentFlag, wikiMessageFlag}, flags.LoginRepoFlags...) + +// CmdWikiCreate represents the create subcommand for wiki pages. +var CmdWikiCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create a wiki page", + Description: "Create a wiki page", + ArgsUsage: " ", + Action: RunWikiCreate, + Flags: wikiWriteFlags, +} + +// CmdWikiEdit represents the edit subcommand for wiki pages. +var CmdWikiEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit a wiki page", + Description: "Edit a wiki page", + ArgsUsage: "", + Action: RunWikiEdit, + Flags: wikiWriteFlags, +} + +// CmdWikiDelete represents the delete subcommand for wiki pages. +var CmdWikiDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a wiki page", + Description: "Delete a wiki page", + ArgsUsage: "", + Action: RunWikiDelete, + Flags: append([]cli.Flag{wikiDeleteConfirmFlag}, flags.LoginRepoFlags...), +} + +func getWikiCreateOptions(cmd *cli.Command) (gitea.CreateWikiPageOptions, error) { + return buildWikiWriteOptions(cmd.String("title"), cmd.String("content"), cmd.String("message"), true) +} + +func getWikiEditOptions(cmd *cli.Command) (gitea.CreateWikiPageOptions, error) { + return buildWikiWriteOptions(cmd.String("title"), cmd.String("content"), cmd.String("message"), false) +} + +func buildWikiWriteOptions(title, content, message string, titleRequired bool) (gitea.CreateWikiPageOptions, error) { + if content == "" { + return gitea.CreateWikiPageOptions{}, fmt.Errorf("content is required") + } + if titleRequired && title == "" { + return gitea.CreateWikiPageOptions{}, fmt.Errorf("title is required") + } + return gitea.CreateWikiPageOptions{ + Title: title, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(content)), + Message: message, + }, nil +} + +// RunWikiCreate creates a wiki page. +func RunWikiCreate(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := initWikiWriteContext(cmd) + if err != nil { + return err + } + return runWikiCreateWithClient(ctx, ctx.Login.Client().Wiki, cmd) +} + +// RunWikiEdit edits a wiki page. +func RunWikiEdit(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := initWikiWriteContext(cmd) + if err != nil { + return err + } + return runWikiEditWithClient(ctx, cmd.Args().First(), ctx.Login.Client().Wiki, cmd) +} + +// RunWikiDelete deletes a wiki page. +func RunWikiDelete(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := initWikiWriteContext(cmd) + if err != nil { + return err + } + return runWikiDeleteWithClient(ctx, cmd.Args().First(), ctx.Login.Client().Wiki, cmd.Bool("confirm")) +} + +func runWikiCreateWithClient(ctx *teaContext.TeaContext, client task.WikiWriteClient, cmd *cli.Command) error { + opts, err := getWikiCreateOptions(cmd) + if err != nil { + return err + } + page, err := task.CreateWikiPage(ctx, client, opts) + if err != nil { + return err + } + return print.WikiPageDetails(page, ctx.Output) +} + +func runWikiEditWithClient(ctx *teaContext.TeaContext, pageName string, client task.WikiWriteClient, cmd *cli.Command) error { + if pageName == "" { + return fmt.Errorf("page name is required") + } + opts, err := getWikiEditOptions(cmd) + if err != nil { + return err + } + if opts.Title == "" { + opts.Title = pageName + } + page, err := task.EditWikiPage(ctx, client, pageName, opts) + if err != nil { + return err + } + return print.WikiPageDetails(page, ctx.Output) +} + +func runWikiDeleteWithClient(ctx *teaContext.TeaContext, pageName string, client task.WikiWriteClient, confirm bool) error { + if pageName == "" { + return fmt.Errorf("page name is required") + } + if !confirm { + return fmt.Errorf("deletion requires --confirm") + } + return task.DeleteWikiPage(ctx, client, pageName) +} + +func initWikiWriteContext(cmd *cli.Command) (*teaContext.TeaContext, error) { + return initWikiContext(cmd) +} diff --git a/cmd/wiki/write_test.go b/cmd/wiki/write_test.go new file mode 100644 index 00000000..da18f537 --- /dev/null +++ b/cmd/wiki/write_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + stdctx "context" + "testing" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +type fakeWikiWriteClient struct { + owner string + repo string + pageName string + create gitea.CreateWikiPageOptions + edit gitea.CreateWikiPageOptions + created *gitea.WikiPage + edited *gitea.WikiPage + deleted bool +} + +func (f *fakeWikiWriteClient) CreatePage(_ stdctx.Context, owner, repo string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.create = opt + return f.created, nil, nil +} + +func (f *fakeWikiWriteClient) EditPage(_ stdctx.Context, owner, repo, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.pageName = pageName + f.edit = opt + return f.edited, nil, nil +} + +func (f *fakeWikiWriteClient) DeletePage(_ stdctx.Context, owner, repo, pageName string) (*gitea.Response, error) { + f.owner = owner + f.repo = repo + f.pageName = pageName + f.deleted = true + return nil, nil +} + +func TestGetWikiCreateOptionsRequiresTitle(t *testing.T) { + _, err := buildWikiWriteOptions("", "# Home", "", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "title") +} + +func TestGetWikiCreateOptionsEncodesContent(t *testing.T) { + opts, err := buildWikiWriteOptions("Home", "# Home", "create home", true) + require.NoError(t, err) + assert.Equal(t, "Home", opts.Title) + assert.Equal(t, "IyBIb21l", opts.ContentBase64) + assert.Equal(t, "create home", opts.Message) +} + +func TestGetWikiEditOptionsAllowsEmptyTitleButRequiresContent(t *testing.T) { + _, err := buildWikiWriteOptions("", "", "edit home", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "content") + + opts, err := buildWikiWriteOptions("", "# Updated", "edit home", false) + require.NoError(t, err) + assert.Equal(t, "", opts.Title) + assert.Equal(t, "IyBVcGRhdGVk", opts.ContentBase64) + assert.Equal(t, "edit home", opts.Message) +} + +func TestWikiWriteCommandsRegistered(t *testing.T) { + assert.Equal(t, "create", CmdWikiCreate.Name) + assert.Equal(t, "edit", CmdWikiEdit.Name) + assert.Equal(t, "delete", CmdWikiDelete.Name) +} + +func TestRunWikiCreateWithClientUsesRepoAndOptions(t *testing.T) { + ctx := newWikiTestContext("json") + client := &fakeWikiWriteClient{created: &gitea.WikiPage{Title: "Home", ContentBase64: "IyBIb21l"}} + cmd := &cli.Command{Flags: []cli.Flag{wikiTitleFlag, wikiContentFlag, wikiMessageFlag}} + require.NoError(t, cmd.Set("title", "Home")) + require.NoError(t, cmd.Set("content", "# Home")) + require.NoError(t, cmd.Set("message", "create home")) + + err := runWikiCreateWithClient(ctx, client, cmd) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.create.Title) + assert.Equal(t, "IyBIb21l", client.create.ContentBase64) + assert.Equal(t, "create home", client.create.Message) +} + +func TestRunWikiEditWithClientUsesPageAndOptions(t *testing.T) { + ctx := newWikiTestContext("json") + client := &fakeWikiWriteClient{edited: &gitea.WikiPage{Title: "Home", ContentBase64: "IyBVcGRhdGVk"}} + cmd := &cli.Command{Flags: []cli.Flag{wikiTitleFlag, wikiContentFlag, wikiMessageFlag}} + require.NoError(t, cmd.Set("content", "# Updated")) + require.NoError(t, cmd.Set("message", "edit home")) + + err := runWikiEditWithClient(ctx, "Home", client, cmd) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.pageName) + assert.Equal(t, "Home", client.edit.Title) + assert.Equal(t, "IyBVcGRhdGVk", client.edit.ContentBase64) + assert.Equal(t, "edit home", client.edit.Message) +} + +func TestRunWikiDeleteWithClientRequiresConfirm(t *testing.T) { + ctx := newWikiTestContext("json") + client := &fakeWikiWriteClient{} + + err := runWikiDeleteWithClient(ctx, "Home", client, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confirm") + assert.False(t, client.deleted) + + err = runWikiDeleteWithClient(ctx, "Home", client, true) + require.NoError(t, err) + assert.True(t, client.deleted) + assert.Equal(t, "Home", client.pageName) +} + +var _ = gitea.CreateWikiPageOptions{} diff --git a/cmd/wiki_test.go b/cmd/wiki_test.go new file mode 100644 index 00000000..cb04f779 --- /dev/null +++ b/cmd/wiki_test.go @@ -0,0 +1,22 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAppIncludesWikiCommand(t *testing.T) { + app := App() + found := false + for _, c := range app.Commands { + if c.Name == "wiki" { + found = true + break + } + } + assert.True(t, found) +} diff --git a/docs/CLI.md b/docs/CLI.md index 6e711010..07416b2e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1657,6 +1657,122 @@ Disable a workflow **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +## wiki + +Manage repository wiki pages + +**--fields, -f**="": Comma-separated list of fields to print. Available values: + title,path,url,sha,author,updated,message + (default: "title,author,updated,sha") + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### list, ls + +List wiki pages of the repository + +**--fields, -f**="": Comma-separated list of fields to print. Available values: + title,path,url,sha,author,updated,message + (default: "title,author,updated,sha") + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### view + +View a wiki page + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### revisions, history + +List revisions of a wiki page + +**--fields, -f**="": Comma-separated list of fields to print. Available values: + sha,message,author,date + (default: "sha,author,date,message") + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### create, c + +Create a wiki page + +**--content, -c**="": wiki page content + +**--login, -l**="": Use a different Gitea Login. Optional + +**--message, -m**="": commit message for the wiki change + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--title, -t**="": wiki page title + +### edit, e + +Edit a wiki page + +**--content, -c**="": wiki page content + +**--login, -l**="": Use a different Gitea Login. Optional + +**--message, -m**="": commit message for the wiki change + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--title, -t**="": wiki page title + +### delete, rm + +Delete a wiki page + +**--confirm, -y**: confirm deletion without prompting + +**--login, -l**="": Use a different Gitea Login. Optional + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ## webhooks, webhook, hooks, hook Manage webhooks diff --git a/modules/print/wiki.go b/modules/print/wiki.go new file mode 100644 index 00000000..1dec3ec7 --- /dev/null +++ b/modules/print/wiki.go @@ -0,0 +1,209 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + + "gitea.dev/sdk" +) + +// WikiPageFields are all available fields to print with WikiPagesList(). +var WikiPageFields = []string{ + "title", + "path", + "url", + "sha", + "author", + "updated", + "message", +} + +// WikiRevisionFields are all available fields to print with WikiRevisionsList(). +var WikiRevisionFields = []string{ + "sha", + "message", + "author", + "date", +} + +// WikiPagesList prints a listing of wiki pages. +func WikiPagesList(pages []*gitea.WikiPageMetaData, output string, fields []string) error { + if len(pages) == 0 && !isMachineReadable(output) { + fmt.Println("No wiki pages found") + return nil + } + + t := wikiPagesTable(pages, fields, isMachineReadable(output)) + return t.print(output) +} + +// WikiPageDetails prints a wiki page. +func WikiPageDetails(page *gitea.WikiPage, output string) error { + if output == "" { + markdown, err := renderWikiPageMarkdown(page) + if err != nil { + return err + } + return outputMarkdown(markdown, getRepoURL(page.HTMLURL)) + } + + t := wikiPageDetailsTable(page, isMachineReadable(output)) + return t.print(output) +} + +// WikiRevisionsList prints a listing of wiki page revisions. +func WikiRevisionsList(revisions []*gitea.WikiCommit, output string, fields []string) error { + t := wikiRevisionsTable(revisions, fields, isMachineReadable(output)) + return t.print(output) +} + +func wikiPagesTable(pages []*gitea.WikiPageMetaData, fields []string, machineReadable bool) table { + printables := make([]printable, len(pages)) + for i, page := range pages { + printables[i] = printableWikiPageMetaData{page} + } + return tableFromItems(fields, printables, machineReadable) +} + +func wikiPageDetailsTable(page *gitea.WikiPage, machineReadable bool) table { + content, _ := decodeWikiContent(page.ContentBase64) + fields := []string{"title", "content", "commits", "path", "url", "sha", "author", "updated", "message"} + return tableFromItems(fields, []printable{printableWikiPage{page: page, content: content}}, machineReadable) +} + +func wikiRevisionsTable(revisions []*gitea.WikiCommit, fields []string, machineReadable bool) table { + printables := make([]printable, len(revisions)) + for i, revision := range revisions { + printables[i] = printableWikiRevision{revision} + } + return tableFromItems(fields, printables, machineReadable) +} + +func renderWikiPageMarkdown(page *gitea.WikiPage) (string, error) { + content, err := decodeWikiContent(page.ContentBase64) + if err != nil { + return "", err + } + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + return content, nil +} + +func decodeWikiContent(contentBase64 string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(contentBase64) + if err != nil { + return "", err + } + return string(decoded), nil +} + +type printableWikiPageMetaData struct{ *gitea.WikiPageMetaData } + +type printableWikiPage struct { + page *gitea.WikiPage + content string +} + +type printableWikiRevision struct{ *gitea.WikiCommit } + +func (x printableWikiPageMetaData) FormatField(field string, machineReadable bool) string { + switch field { + case "title": + return x.Title + case "path": + return x.SubURL + case "url": + return x.HTMLURL + case "sha": + return shortWikiCommitID(x.LastCommit) + case "author": + return wikiCommitAuthor(x.LastCommit) + case "updated": + return wikiCommitDate(x.LastCommit, machineReadable) + case "message": + return wikiCommitMessage(x.LastCommit) + } + return "" +} + +func (x printableWikiPage) FormatField(field string, machineReadable bool) string { + switch field { + case "title": + return x.page.Title + case "content": + return x.content + case "commits": + return fmt.Sprintf("%d", x.page.CommitCount) + case "path": + return x.page.SubURL + case "url": + return x.page.HTMLURL + case "sha": + return shortWikiCommitID(x.page.LastCommit) + case "author": + return wikiCommitAuthor(x.page.LastCommit) + case "updated": + return wikiCommitDate(x.page.LastCommit, machineReadable) + case "message": + return wikiCommitMessage(x.page.LastCommit) + } + return "" +} + +func (x printableWikiRevision) FormatField(field string, machineReadable bool) string { + switch field { + case "sha": + return shortWikiCommitID(x.WikiCommit) + case "message": + return x.Message + case "author": + return wikiCommitAuthor(x.WikiCommit) + case "date": + return wikiCommitDate(x.WikiCommit, machineReadable) + } + return "" +} + +func shortWikiCommitID(commit *gitea.WikiCommit) string { + if commit == nil { + return "" + } + if len(commit.ID) <= 7 { + return commit.ID + } + return commit.ID[:7] +} + +func wikiCommitAuthor(commit *gitea.WikiCommit) string { + if commit == nil || commit.Author == nil { + return "" + } + if commit.Author.Name != "" { + return commit.Author.Name + } + return commit.Author.Email +} + +func wikiCommitDate(commit *gitea.WikiCommit, machineReadable bool) string { + if commit == nil || commit.Author == nil || commit.Author.Date == "" { + return "" + } + parsed, err := time.Parse(time.RFC3339, commit.Author.Date) + if err != nil { + return commit.Author.Date + } + return FormatTime(parsed, machineReadable) +} + +func wikiCommitMessage(commit *gitea.WikiCommit) string { + if commit == nil { + return "" + } + return commit.Message +} diff --git a/modules/print/wiki_test.go b/modules/print/wiki_test.go new file mode 100644 index 00000000..1c0ef4f5 --- /dev/null +++ b/modules/print/wiki_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWikiPagesTableJSON(t *testing.T) { + pages := []*gitea.WikiPageMetaData{{ + Title: "Home", + HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home", + SubURL: "/octo/tea/wiki/Home", + LastCommit: &gitea.WikiCommit{ + ID: "abcdef123456", + Message: "update home", + Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "alice"}, Date: "2026-05-21T01:02:03Z"}, + }, + }} + + tbl := wikiPagesTable(pages, []string{"title", "author", "updated", "sha", "url"}, true) + buf := &bytes.Buffer{} + require.NoError(t, tbl.fprint(buf, "json")) + + var rows []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &rows)) + require.Len(t, rows, 1) + assert.Equal(t, "Home", rows[0]["title"]) + assert.Equal(t, "alice", rows[0]["author"]) + assert.Equal(t, "2026-05-21T01:02:03Z", rows[0]["updated"]) + assert.Equal(t, "abcdef1", rows[0]["sha"]) + assert.Equal(t, "https://gitea.example.com/octo/tea/wiki/Home", rows[0]["url"]) +} + +func TestWikiRevisionsTableJSON(t *testing.T) { + revisions := []*gitea.WikiCommit{{ + ID: "1234567890abcdef", + Message: "second revision", + Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "bob"}, Date: "2026-05-22T03:04:05Z"}, + }} + + tbl := wikiRevisionsTable(revisions, []string{"sha", "message", "author", "date"}, true) + buf := &bytes.Buffer{} + require.NoError(t, tbl.fprint(buf, "json")) + + var rows []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &rows)) + require.Len(t, rows, 1) + assert.Equal(t, "1234567", rows[0]["sha"]) + assert.Equal(t, "second revision", rows[0]["message"]) + assert.Equal(t, "bob", rows[0]["author"]) + assert.Equal(t, "2026-05-22T03:04:05Z", rows[0]["date"]) +} + +func TestRenderWikiPageMarkdownReturnsDecodedWikiContent(t *testing.T) { + page := &gitea.WikiPage{ + Title: "Home", + HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home", + ContentBase64: "IyBIZWxsbyB3aWtp", + LastCommit: &gitea.WikiCommit{ + Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "carol"}, Date: "2026-05-23T06:07:08Z"}, + }, + } + + out, err := renderWikiPageMarkdown(page) + require.NoError(t, err) + assert.Equal(t, "# Hello wiki\n", out) +} + +func TestWikiPagesListEmptyHumanOutput(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, WikiPagesList(nil, "", []string{"title"})) + }) + + assert.Equal(t, "No wiki pages found\n", out) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + defer func() { + os.Stdout = oldStdout + }() + + done := make(chan string, 1) + go func() { + var outBuf bytes.Buffer + _, _ = io.Copy(&outBuf, reader) + done <- outBuf.String() + }() + + fn() + require.NoError(t, writer.Close()) + output := <-done + require.NoError(t, reader.Close()) + return output +} diff --git a/modules/task/wiki.go b/modules/task/wiki.go new file mode 100644 index 00000000..9e9d2b12 --- /dev/null +++ b/modules/task/wiki.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + stdctx "context" + "fmt" + + "gitea.dev/sdk" + teaContext "gitea.dev/tea/modules/context" +) + +// WikiWriteClient defines the wiki write methods required by task helpers. +type WikiWriteClient interface { + CreatePage(ctx stdctx.Context, owner, repo string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) + EditPage(ctx stdctx.Context, owner, repo, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) + DeletePage(ctx stdctx.Context, owner, repo, pageName string) (*gitea.Response, error) +} + +// CreateWikiPage creates a wiki page and returns the created page. +func CreateWikiPage(ctx *teaContext.TeaContext, client WikiWriteClient, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, error) { + if opt.Title == "" { + return nil, fmt.Errorf("title is required") + } + if opt.ContentBase64 == "" { + return nil, fmt.Errorf("content is required") + } + page, _, err := client.CreatePage(stdctx.Background(), ctx.Owner, ctx.Repo, opt) + if err != nil { + return nil, fmt.Errorf("could not create wiki page: %s", err) + } + return page, nil +} + +// EditWikiPage edits a wiki page and returns the updated page. +func EditWikiPage(ctx *teaContext.TeaContext, client WikiWriteClient, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, error) { + if pageName == "" { + return nil, fmt.Errorf("page name is required") + } + if opt.ContentBase64 == "" { + return nil, fmt.Errorf("content is required") + } + page, _, err := client.EditPage(stdctx.Background(), ctx.Owner, ctx.Repo, pageName, opt) + if err != nil { + return nil, fmt.Errorf("could not edit wiki page: %s", err) + } + return page, nil +} + +// DeleteWikiPage deletes a wiki page. +func DeleteWikiPage(ctx *teaContext.TeaContext, client WikiWriteClient, pageName string) error { + if pageName == "" { + return fmt.Errorf("page name is required") + } + _, err := client.DeletePage(stdctx.Background(), ctx.Owner, ctx.Repo, pageName) + if err != nil { + return fmt.Errorf("could not delete wiki page: %s", err) + } + return nil +} diff --git a/modules/task/wiki_test.go b/modules/task/wiki_test.go new file mode 100644 index 00000000..f06d3835 --- /dev/null +++ b/modules/task/wiki_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + stdctx "context" + "testing" + + "gitea.dev/sdk" + teaContext "gitea.dev/tea/modules/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeWikiWriteClient struct { + owner string + repo string + pageName string + create gitea.CreateWikiPageOptions + edit gitea.CreateWikiPageOptions + created *gitea.WikiPage + edited *gitea.WikiPage + deleted bool +} + +func (f *fakeWikiWriteClient) CreatePage(_ stdctx.Context, owner, repo string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.create = opt + return f.created, nil, nil +} + +func (f *fakeWikiWriteClient) EditPage(_ stdctx.Context, owner, repo, pageName string, opt gitea.CreateWikiPageOptions) (*gitea.WikiPage, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.pageName = pageName + f.edit = opt + return f.edited, nil, nil +} + +func (f *fakeWikiWriteClient) DeletePage(_ stdctx.Context, owner, repo, pageName string) (*gitea.Response, error) { + f.owner = owner + f.repo = repo + f.pageName = pageName + f.deleted = true + return nil, nil +} + +func newWikiTaskContext() *teaContext.TeaContext { + return &teaContext.TeaContext{Owner: "octo", Repo: "tea"} +} + +func TestCreateWikiPageCallsSDK(t *testing.T) { + client := &fakeWikiWriteClient{created: &gitea.WikiPage{Title: "Home"}} + page, err := CreateWikiPage(newWikiTaskContext(), client, gitea.CreateWikiPageOptions{Title: "Home", ContentBase64: "IyBIb21l"}) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.create.Title) + assert.Equal(t, "Home", page.Title) +} + +func TestEditWikiPageCallsSDK(t *testing.T) { + client := &fakeWikiWriteClient{edited: &gitea.WikiPage{Title: "Home"}} + page, err := EditWikiPage(newWikiTaskContext(), client, "Home", gitea.CreateWikiPageOptions{ContentBase64: "IyBVcGRhdGVk"}) + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.pageName) + assert.Equal(t, "IyBVcGRhdGVk", client.edit.ContentBase64) + assert.Equal(t, "Home", page.Title) +} + +func TestDeleteWikiPageCallsSDK(t *testing.T) { + client := &fakeWikiWriteClient{} + err := DeleteWikiPage(newWikiTaskContext(), client, "Home") + require.NoError(t, err) + assert.Equal(t, "octo", client.owner) + assert.Equal(t, "tea", client.repo) + assert.Equal(t, "Home", client.pageName) + assert.True(t, client.deleted) +} diff --git a/tests/integration/wiki_test.go b/tests/integration/wiki_test.go new file mode 100644 index 00000000..253fa693 --- /dev/null +++ b/tests/integration/wiki_test.go @@ -0,0 +1,170 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "gitea.dev/sdk" + teacmd "gitea.dev/tea/cmd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWikiCommandLifecycle(t *testing.T) { + login := createIntegrationLogin(t) + client := login.Client() + repoName := fmt.Sprintf("tea-wiki-integration-%d", time.Now().UnixNano()) + + _, _, err := client.CreateRepo(context.Background(), gitea.CreateRepoOption{Name: repoName}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.DeleteRepo(context.Background(), login.User, repoName); delErr != nil { + t.Logf("failed to delete integration test repo %q: %v", repoName, delErr) + } + }) + + repoSlug := fmt.Sprintf("%s/%s", login.User, repoName) + + createOutput := runTeaCommand(t, + "wiki", "create", + "--login", login.Name, + "--repo", repoSlug, + "--title", "Home", + "--content", "# Home", + "--message", "create home", + "--output", "json", + ) + assert.Contains(t, createOutput, "\"title\": \"Home\"") + + page, _, err := client.Wiki.GetPage(context.Background(), login.User, repoName, "Home") + require.NoError(t, err) + assert.Equal(t, "Home", page.Title) + assert.Contains(t, decodeBase64String(t, page.ContentBase64), "# Home") + + listOutput := runTeaCommand(t, + "wiki", "list", + "--login", login.Name, + "--repo", repoSlug, + "--output", "json", + ) + assert.Contains(t, listOutput, "\"title\": \"Home\"") + + emptyRepoName := fmt.Sprintf("tea-wiki-empty-%d", time.Now().UnixNano()) + _, _, err = client.CreateRepo(context.Background(), gitea.CreateRepoOption{Name: emptyRepoName}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.DeleteRepo(context.Background(), login.User, emptyRepoName); delErr != nil { + t.Logf("failed to delete empty wiki repo %q: %v", emptyRepoName, delErr) + } + }) + emptyRepoSlug := fmt.Sprintf("%s/%s", login.User, emptyRepoName) + emptyListOutput := runTeaCommand(t, + "wiki", "list", + "--login", login.Name, + "--repo", emptyRepoSlug, + ) + assert.Contains(t, emptyListOutput, "No wiki pages found") + + viewOutput := runTeaCommand(t, + "wiki", "view", "Home", + "--login", login.Name, + "--repo", repoSlug, + "--output", "json", + ) + assert.Contains(t, viewOutput, "\"title\": \"Home\"") + assert.Contains(t, viewOutput, "\"content\": \"# Home\"") + + runTeaCommand(t, + "wiki", "edit", "Home", + "--login", login.Name, + "--repo", repoSlug, + "--content", "# Updated", + "--message", "edit home", + "--output", "json", + ) + + page, _, err = client.Wiki.GetPage(context.Background(), login.User, repoName, "Home") + require.NoError(t, err) + assert.Equal(t, "Home", page.Title) + assert.Contains(t, decodeBase64String(t, page.ContentBase64), "# Updated") + + viewRenderedOutput := runTeaCommand(t, + "wiki", "view", "Home", + "--login", login.Name, + "--repo", repoSlug, + ) + assert.Contains(t, viewRenderedOutput, "# Updated") + assert.NotContains(t, viewRenderedOutput, "Last updated") + + revisionsOutput := runTeaCommand(t, + "wiki", "revisions", "Home", + "--login", login.Name, + "--repo", repoSlug, + "--output", "json", + ) + assert.Contains(t, revisionsOutput, "create home") + assert.Contains(t, revisionsOutput, "edit home") + + runTeaCommand(t, + "wiki", "delete", "Home", + "--login", login.Name, + "--repo", repoSlug, + "--confirm", + ) + + _, _, err = client.Wiki.GetPage(context.Background(), login.User, repoName, "Home") + require.Error(t, err) + assert.True(t, strings.Contains(strings.ToLower(err.Error()), "not found") || strings.Contains(strings.ToLower(err.Error()), "404")) +} + +func runTeaCommand(t *testing.T, args ...string) string { + t.Helper() + + app := teacmd.App() + errBuf := &bytes.Buffer{} + app.Writer = io.Discard + app.ErrWriter = errBuf + + oldStdout := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + defer func() { + os.Stdout = oldStdout + }() + + done := make(chan string, 1) + go func() { + var outBuf bytes.Buffer + _, _ = io.Copy(&outBuf, reader) + done <- outBuf.String() + }() + + runErr := app.Run(context.Background(), append([]string{"tea"}, args...)) + require.NoError(t, writer.Close()) + output := <-done + require.NoError(t, reader.Close()) + + require.NoError(t, runErr, "tea %s failed with stdout: %s\nstderr: %s", strings.Join(args, " "), output, errBuf.String()) + + return output +} + +func decodeBase64String(t *testing.T, encoded string) string { + t.Helper() + + decoded, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + + return string(decoded) +}