feat: Add 'tea deploy-tokens' command for repository deploy token management

- Add deploy-tokens subcommand with list, create, delete
- Create deploy token print helpers with token display (shown once only)
- Add unit tests for all commands
- Update SDK dependency to use fork with deploy token support

Relates to gitea/go-sdk#846
This commit is contained in:
Ross Golder 2026-09-08 11:05:40 +07:00
parent e499d0843c
commit 803aeab5db
No known key found for this signature in database
GPG key ID: 253A7E508D2D59CD
11 changed files with 623 additions and 2 deletions

View file

@ -43,6 +43,7 @@ func App() *cli.Command {
&CmdWebhooks,
&CmdComments,
&CmdDeployKeys,
&CmdDeployTokens,
&CmdOpen,
&CmdNotifications,

64
cmd/deploy_tokens.go Normal file
View file

@ -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: "[<token-id>]",
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
}

View file

@ -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: "<token-title>",
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
}

View file

@ -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, "<token-title>", 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)
})
}
}

View file

@ -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: "<token-id>",
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
}

View file

@ -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, "<token-id>", 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")
}

57
cmd/deploytokens/list.go Normal file
View file

@ -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
}

View file

@ -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
}
}

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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)
}
}