diff --git a/cmd/cmd.go b/cmd/cmd.go index 686a37e8..74653279 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -43,6 +43,7 @@ func App() *cli.Command { &CmdWebhooks, &CmdComments, &CmdDeployKeys, + &CmdDeployTokens, &CmdOpen, &CmdNotifications, diff --git a/cmd/deploy_tokens.go b/cmd/deploy_tokens.go new file mode 100644 index 00000000..1379d34c --- /dev/null +++ b/cmd/deploy_tokens.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/deploytokens" + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdDeployTokens represents the deploy-tokens command for managing repository +// deploy tokens (HTTPS deploy keys). +var CmdDeployTokens = cli.Command{ + Name: "deploy-tokens", + Aliases: []string{"dt", "deploy-token"}, + Category: catEntities, + Usage: "Manage repository deploy tokens", + Description: "List, create, and delete deploy tokens (HTTPS deploy keys) for a repository.", + ArgsUsage: "[]", + Action: runDeployTokensDefault, + Commands: []*cli.Command{ + &deploytokens.CmdDeployTokensList, + &deploytokens.CmdDeployTokensCreate, + &deploytokens.CmdDeployTokensDelete, + }, + Flags: flags.AllDefaultFlags, +} + +func runDeployTokensDefault(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runDeployTokenDetail(ctx, cmd, cmd.Args().First()) + } + return deploytokens.RunDeployTokensList(ctx, cmd) +} + +func runDeployTokenDetail(ctx stdctx.Context, cmd *cli.Command, arg string) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + keyID, err := utils.ArgToIndex(arg) + if err != nil { + return err + } + + key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID) + if err != nil { + return err + } + print.DeployTokenDetails(key) + return nil +} diff --git a/cmd/deploytokens/create.go b/cmd/deploytokens/create.go new file mode 100644 index 00000000..c7b52701 --- /dev/null +++ b/cmd/deploytokens/create.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploytokens + +import ( + stdctx "context" + "errors" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var CmdDeployTokensCreate = cli.Command{ + Name: "create", + Aliases: []string{"add", "c"}, + Usage: "Create a deploy token", + Description: "Create a deploy token in a repository. The token is shown only once at creation time.", + ArgsUsage: "", + Action: runDeployTokensCreate, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "read-only", + Usage: "restrict the token to read-only access (default)", + Value: true, + }, + &cli.BoolFlag{ + Name: "read-write", + Usage: "grant the token read/write access", + }, + }, flags.AllDefaultFlags...), +} + +func runDeployTokensCreate(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return errors.New("token title is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + readOnly := cmd.Bool("read-only") + if cmd.IsSet("read-write") { + readOnly = !cmd.Bool("read-write") + } + + key, _, err := client.CreateDeployToken(ctx, c.Owner, c.Repo, gitea.CreateDeployKeyTokenOption{ + Title: cmd.Args().First(), + ReadOnly: readOnly, + }) + if err != nil { + return err + } + + fmt.Printf("Deploy token %d created\n", key.ID) + print.DeployTokenDetails(key) + return nil +} diff --git a/cmd/deploytokens/create_test.go b/cmd/deploytokens/create_test.go new file mode 100644 index 00000000..18712653 --- /dev/null +++ b/cmd/deploytokens/create_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploytokens + +import ( + "testing" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestCreateCommandMetadata(t *testing.T) { + cmd := &CmdDeployTokensCreate + + assert.Equal(t, "create", cmd.Name) + for _, want := range []string{"add", "c"} { + assert.Contains(t, cmd.Aliases, want) + } + assert.Equal(t, "Create a deploy token", cmd.Usage) + assert.Equal(t, "", cmd.ArgsUsage) + assert.NotNil(t, cmd.Action) +} + +func TestCreateCommandFlags(t *testing.T) { + cmd := &CmdDeployTokensCreate + + have := make(map[string]bool, len(cmd.Flags)) + for _, flag := range cmd.Flags { + have[flag.Names()[0]] = true + } + for _, name := range []string{"read-only", "read-write", "login", "repo", "remote", "output"} { + assert.True(t, have[name], "expected flag %q not found", name) + } +} + +func TestCreateReadOnlyDefault(t *testing.T) { + for _, flag := range CmdDeployTokensCreate.Flags { + if bf, ok := flag.(*cli.BoolFlag); ok && bf.Name == "read-only" { + assert.True(t, bf.Value, "--read-only should default to true") + } + } +} + +func TestCreateTokenOptionConstruction(t *testing.T) { + tests := []struct { + name string + title string + readOnly bool + }{ + {"read-only token", "ci", true}, + {"read-write token", "deploy-bot", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opt := gitea.CreateDeployKeyTokenOption{ + Title: tt.title, + ReadOnly: tt.readOnly, + } + assert.Equal(t, tt.title, opt.Title) + assert.Equal(t, tt.readOnly, opt.ReadOnly) + }) + } +} diff --git a/cmd/deploytokens/delete.go b/cmd/deploytokens/delete.go new file mode 100644 index 00000000..ebc52dfe --- /dev/null +++ b/cmd/deploytokens/delete.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploytokens + +import ( + stdctx "context" + "errors" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +var CmdDeployTokensDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a deploy token", + Description: "Delete a deploy token by ID from a repository", + ArgsUsage: "", + Action: runDeployTokensDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runDeployTokensDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return errors.New("deploy token ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + keyID, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid deploy token ID: %w", err) + } + + key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID) + if err != nil { + return err + } + + if key.KeyType != "token" { + return fmt.Errorf("key %d is not a deploy token (type: %s)", keyID, key.KeyType) + } + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete deploy token %d (%s)? [y/N] ", key.ID, key.Title) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + if _, err = client.DeleteDeployKey(ctx, c.Owner, c.Repo, keyID); err != nil { + return err + } + + fmt.Printf("Deploy token %d deleted successfully\n", keyID) + return nil +} diff --git a/cmd/deploytokens/delete_test.go b/cmd/deploytokens/delete_test.go new file mode 100644 index 00000000..a79e3cb0 --- /dev/null +++ b/cmd/deploytokens/delete_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploytokens + +import ( + "strconv" + "testing" + + "gitea.dev/sdk" + "gitea.dev/tea/modules/utils" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestDeleteCommandMetadata(t *testing.T) { + cmd := &CmdDeployTokensDelete + + assert.Equal(t, "delete", cmd.Name) + assert.Contains(t, cmd.Aliases, "rm") + assert.Equal(t, "Delete a deploy token", cmd.Usage) + assert.Equal(t, "Delete a deploy token by ID from a repository", cmd.Description) + assert.Equal(t, "", cmd.ArgsUsage) + assert.NotNil(t, cmd.Action) +} + +func TestDeleteCommandFlags(t *testing.T) { + cmd := &CmdDeployTokensDelete + + var confirmFlag *cli.BoolFlag + for _, flag := range cmd.Flags { + if flag.Names()[0] == "confirm" { + confirmFlag, _ = flag.(*cli.BoolFlag) + break + } + } + assert.NotNil(t, confirmFlag, "confirm flag should exist") + assert.Contains(t, confirmFlag.Aliases, "y") +} + +func TestDeleteConfirmationLogic(t *testing.T) { + tests := []struct { + name string + confirmFlag bool + userResponse string + shouldDelete bool + }{ + {"--confirm set", true, "", true}, + {"user says y", false, "y", true}, + {"user says Y", false, "Y", true}, + {"user says yes", false, "yes", true}, + {"user says n", false, "n", false}, + {"user says N", false, "N", false}, + {"user says no", false, "no", false}, + {"empty response", false, "", false}, + {"unknown response", false, "maybe", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shouldDelete := tt.confirmFlag + if !tt.confirmFlag { + r := tt.userResponse + shouldDelete = r == "y" || r == "Y" || r == "yes" + } + assert.Equal(t, tt.shouldDelete, shouldDelete) + }) + } +} + +func TestDeleteTokenIDParsing(t *testing.T) { + tests := []struct { + name string + input string + wantID int64 + expectErr bool + }{ + {"single digit", "1", 1, false}, + {"multi digit", "123", 123, false}, + {"with hash prefix", "#42", 42, false}, + {"zero", "0", 0, false}, + {"negative", "-1", -1, false}, + {"non-numeric", "abc", 0, true}, + {"float", "12.5", 0, true}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := utils.ArgToIndex(tt.input) + if tt.expectErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantID, id) + }) + } +} + +func TestDeletePromptMessage(t *testing.T) { + key := &gitea.DeployKey{ID: 7, Title: "ci-runner", KeyType: "token"} + + msg := "Are you sure you want to delete deploy token " + strconv.FormatInt(key.ID, 10) + " (" + key.Title + ")? [y/N] " + assert.Contains(t, msg, "7") + assert.Contains(t, msg, "ci-runner") + assert.Contains(t, msg, "[y/N]") +} + +func TestDeleteOnlyAllowsTokens(t *testing.T) { + sshKey := &gitea.DeployKey{ID: 1, Title: "ssh-key", KeyType: "ssh"} + token := &gitea.DeployKey{ID: 2, Title: "token", KeyType: "token"} + + assert.NotEqual(t, sshKey.KeyType, "token") + assert.Equal(t, token.KeyType, "token") +} diff --git a/cmd/deploytokens/list.go b/cmd/deploytokens/list.go new file mode 100644 index 00000000..925f5ff2 --- /dev/null +++ b/cmd/deploytokens/list.go @@ -0,0 +1,57 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploytokens + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var CmdDeployTokensList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List deploy tokens", + Description: "List deploy tokens of a repository", + Action: RunDeployTokensList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +func RunDeployTokensList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + opts := gitea.ListDeployKeysOptions{ + ListOptions: flags.GetListOptions(cmd), + } + + keys, _, err := client.ListDeployKeys(ctx, c.Owner, c.Repo, opts) + if err != nil { + return err + } + + var tokens []*gitea.DeployKey + for _, key := range keys { + if key.KeyType == "token" { + tokens = append(tokens, key) + } + } + + print.DeployTokensList(tokens, c.Output) + return nil +} diff --git a/cmd/deploytokens/list_test.go b/cmd/deploytokens/list_test.go new file mode 100644 index 00000000..8d0ddcdb --- /dev/null +++ b/cmd/deploytokens/list_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploytokens + +import ( + "testing" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" +) + +func TestListCommandMetadata(t *testing.T) { + cmd := &CmdDeployTokensList + + assert.Equal(t, "list", cmd.Name) + assert.Contains(t, cmd.Aliases, "ls") + assert.Equal(t, "List deploy tokens", cmd.Usage) + assert.Equal(t, "List deploy tokens of a repository", cmd.Description) + assert.NotNil(t, cmd.Action) +} + +func TestListCommandFlags(t *testing.T) { + cmd := &CmdDeployTokensList + + expectedFlags := []string{ + "page", + "limit", + "login", + "repo", + "remote", + "output", + } + + have := make(map[string]bool, len(cmd.Flags)) + for _, flag := range cmd.Flags { + have[flag.Names()[0]] = true + } + for _, name := range expectedFlags { + assert.True(t, have[name], "expected flag %q not found", name) + } +} + +func TestListOptionsConstruction(t *testing.T) { + tests := []struct { + name string + page int + limit int + }{ + { + name: "default options", + page: 1, + limit: 30, + }, + { + name: "custom pagination", + page: 3, + limit: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := gitea.ListDeployKeysOptions{ + ListOptions: gitea.ListOptions{ + Page: tt.page, + PageSize: tt.limit, + }, + } + assert.Equal(t, tt.page, opts.Page) + assert.Equal(t, tt.limit, opts.PageSize) + }) + } +} + +func TestListCommandStructure(t *testing.T) { + cmd := &CmdDeployTokensList + + assert.NotEmpty(t, cmd.Name) + assert.NotEmpty(t, cmd.Usage) + assert.NotEmpty(t, cmd.Description) + assert.NotNil(t, cmd.Action) + + for _, alias := range cmd.Aliases { + assert.NotEmpty(t, alias) + assert.NotContains(t, alias, " ") + } +} + +func TestListOutputFormats(t *testing.T) { + supportedFormats := []string{ + "table", "csv", "tsv", "simple", "yaml", "json", + } + for _, format := range supportedFormats { + t.Run("Format_"+format, func(t *testing.T) { + assert.NotEmpty(t, format) + assert.NotContains(t, format, " ") + }) + } +} + +func TestListTableHeaders(t *testing.T) { + expectedHeaders := []string{ + "ID", + "Title", + "Type", + "Fingerprint", + "Read-Only", + "Created", + } + + headerSet := make(map[string]bool, len(expectedHeaders)) + for _, h := range expectedHeaders { + assert.False(t, headerSet[h], "duplicate header %q", h) + headerSet[h] = true + } +} diff --git a/go.mod b/go.mod index 9bcda1b6..b4d95fe9 100644 --- a/go.mod +++ b/go.mod @@ -85,3 +85,5 @@ require ( ) retract v1.3.3 // accidental release, tag deleted + +replace gitea.dev/sdk => gitea.com/rossigee/go-sdk v1.3.0-beta.1 diff --git a/go.sum b/go.sum index 05e7ea1d..e5404af2 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= -gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= -gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= +gitea.com/rossigee/go-sdk v1.3.0-beta.1 h1:0hYckuSv0VyzR4apOSwV0kZXWvQ4H+DQRvv//7D1Xv4= +gitea.com/rossigee/go-sdk v1.3.0-beta.1/go.mod h1:xvE6Mps2jltMgK1A3CjTzTN+AilIIdV5gBTOnTZ3ZHU= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= diff --git a/modules/print/deploy_token.go b/modules/print/deploy_token.go new file mode 100644 index 00000000..1faeb3a9 --- /dev/null +++ b/modules/print/deploy_token.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "fmt" + "strconv" + + "gitea.dev/sdk" +) + +func DeployTokensList(keys []*gitea.DeployKey, output string) { + t := tableWithHeader( + "ID", + "Title", + "Type", + "Fingerprint", + "Read-Only", + "Created", + ) + + for _, key := range keys { + readOnly := "no" + if key.ReadOnly { + readOnly = "yes" + } + + t.addRow( + strconv.FormatInt(key.ID, 10), + key.Title, + key.KeyType, + key.Fingerprint, + readOnly, + FormatTime(key.Created, false), + ) + } + + t.print(output) +} + +func DeployTokenDetails(key *gitea.DeployKey) { + fmt.Printf("# Deploy Token %d\n\n", key.ID) + fmt.Printf("- **Title**: %s\n", key.Title) + fmt.Printf("- **Type**: %s\n", key.KeyType) + fmt.Printf("- **Fingerprint**: %s\n", key.Fingerprint) + fmt.Printf("- **Read-Only**: %t\n", key.ReadOnly) + fmt.Printf("- **Created**: %s\n", FormatTime(key.Created, false)) + if key.Token != "" { + fmt.Printf("- **Token**: %s (shown once only)\n", key.Token) + } +}