diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4d1b70..c8ececc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +* FEATURES + * Add `tea deploy-keys` command for listing, creating, and deleting repository deploy keys (`tea deploy-keys list`, `tea deploy-keys create --key-file <path>`, `tea deploy-keys delete <id>`) + ## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05 * FEATURES diff --git a/README.md b/README.md index 2293b841..82c0c0bb 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ EXAMPLES tea webhooks list # list repository webhooks tea webhooks list --org myorg # list organization webhooks tea webhooks create https://example.com/hook --events push,pull_request + tea deploy-keys list # list repository deploy keys + tea deploy-keys create CI --key-file ~/.ssh/id_ed25519.pub --read-write + tea deploy-keys delete 7 -y # remove a deploy key without prompting # send gitea desktop notifications every 5 minutes (bash + libnotify) while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done diff --git a/cmd/cmd.go b/cmd/cmd.go index a2306d9c..686a37e8 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -42,6 +42,7 @@ func App() *cli.Command { &CmdWiki, &CmdWebhooks, &CmdComments, + &CmdDeployKeys, &CmdOpen, &CmdNotifications, diff --git a/cmd/deploy_keys.go b/cmd/deploy_keys.go new file mode 100644 index 00000000..2ffc08b2 --- /dev/null +++ b/cmd/deploy_keys.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/deploykeys" + "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" +) + +// CmdDeployKeys represents the deploy-keys command for managing repository +// deploy keys. +var CmdDeployKeys = cli.Command{ + Name: "deploy-keys", + Aliases: []string{"dk", "deploy-key"}, + Category: catEntities, + Usage: "Manage repository deploy keys", + Description: "List, create, and delete deploy keys for a repository. To replace a key, run 'delete' then 'create'.", + ArgsUsage: "[<key-id>]", + Action: runDeployKeysDefault, + Commands: []*cli.Command{ + &deploykeys.CmdDeployKeysList, + &deploykeys.CmdDeployKeysCreate, + &deploykeys.CmdDeployKeysDelete, + }, + Flags: flags.AllDefaultFlags, +} + +func runDeployKeysDefault(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runDeployKeyDetail(ctx, cmd, cmd.Args().First()) + } + return deploykeys.RunDeployKeysList(ctx, cmd) +} + +func runDeployKeyDetail(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.DeployKeyDetails(key) + return nil +} diff --git a/cmd/deploykeys/create.go b/cmd/deploykeys/create.go new file mode 100644 index 00000000..4afc08ac --- /dev/null +++ b/cmd/deploykeys/create.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploykeys + +import ( + stdctx "context" + "errors" + "fmt" + "os" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdDeployKeysCreate represents a sub command of deploy-keys to create a deploy key +var CmdDeployKeysCreate = cli.Command{ + Name: "create", + Aliases: []string{"add", "c"}, + Usage: "Create a deploy key", + Description: "Create a deploy key in a repository. To replace an existing key, run 'delete' followed by 'create'.", + ArgsUsage: "<key-title>", + Action: runDeployKeysCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "key", + Usage: "inline armored SSH public key", + }, + &cli.StringFlag{ + Name: "key-file", + Usage: "path to a file containing the armored SSH public key (e.g. ~/.ssh/id_ed25519.pub)", + }, + &cli.BoolFlag{ + Name: "read-only", + Usage: "restrict the key to read-only access (default)", + Value: true, + }, + &cli.BoolFlag{ + Name: "read-write", + Usage: "grant the key read/write access", + }, + }, flags.AllDefaultFlags...), +} + +func runDeployKeysCreate(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return errors.New("key 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() + + keyMaterial, err := resolveKeyMaterial(cmd.String("key"), cmd.String("key-file")) + if err != nil { + return err + } + + readOnly := cmd.Bool("read-only") + if cmd.IsSet("read-write") { + readOnly = !cmd.Bool("read-write") + } + + key, _, err := client.CreateDeployKey(ctx, c.Owner, c.Repo, gitea.CreateKeyOption{ + Title: cmd.Args().First(), + Key: keyMaterial, + ReadOnly: readOnly, + }) + if err != nil { + return err + } + + fmt.Printf("Deploy key %d created (title: %s)\n", key.ID, key.Title) + return nil +} + +// resolveKeyMaterial returns the SSH public key from --key (inline) or --key-file. +// The two flags are mutually exclusive; exactly one must be supplied. +func resolveKeyMaterial(inline, file string) (string, error) { + switch { + case inline != "" && file != "": + return "", errors.New("--key and --key-file are mutually exclusive") + case inline == "" && file == "": + return "", errors.New("either --key or --key-file is required") + case inline != "": + return inline, nil + default: + data, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("reading key file %q: %w", file, err) + } + return string(data), nil + } +} diff --git a/cmd/deploykeys/create_test.go b/cmd/deploykeys/create_test.go new file mode 100644 index 00000000..041efc09 --- /dev/null +++ b/cmd/deploykeys/create_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploykeys + +import ( + "os" + "path/filepath" + "testing" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestCreateCommandMetadata(t *testing.T) { + cmd := &CmdDeployKeysCreate + + assert.Equal(t, "create", cmd.Name) + for _, want := range []string{"add", "c"} { + assert.Contains(t, cmd.Aliases, want) + } + assert.Equal(t, "Create a deploy key", cmd.Usage) + assert.Equal(t, "<key-title>", cmd.ArgsUsage) + assert.NotNil(t, cmd.Action) +} + +func TestCreateCommandFlags(t *testing.T) { + cmd := &CmdDeployKeysCreate + + have := make(map[string]bool, len(cmd.Flags)) + for _, flag := range cmd.Flags { + have[flag.Names()[0]] = true + } + for _, name := range []string{"key", "key-file", "read-only", "read-write", "login", "repo", "remote", "output"} { + assert.True(t, have[name], "expected flag %q not found", name) + } +} + +func TestCreateReadOnlyDefault(t *testing.T) { + // The --read-only flag must default to true to match Gitea's "safe" default + // and the flag-help wording ("(default)"). + for _, flag := range CmdDeployKeysCreate.Flags { + if bf, ok := flag.(*cli.BoolFlag); ok && bf.Name == "read-only" { + assert.True(t, bf.Value, "--read-only should default to true") + } + } +} + +func TestResolveKeyMaterial(t *testing.T) { + const inlineKey = "ssh-ed25519 AAAA-test-key inline" + + t.Run("inline key", func(t *testing.T) { + got, err := resolveKeyMaterial(inlineKey, "") + assert.NoError(t, err) + assert.Equal(t, inlineKey, got) + }) + + t.Run("mutually exclusive flags", func(t *testing.T) { + _, err := resolveKeyMaterial(inlineKey, "/tmp/key.pub") + assert.Error(t, err) + }) + + t.Run("neither flag set", func(t *testing.T) { + _, err := resolveKeyMaterial("", "") + assert.Error(t, err) + }) + + t.Run("read key from file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "key.pub") + want := inlineKey + " trailing-newline\n" + require := assert.New(t) + require.NoError(os.WriteFile(path, []byte(want), 0o600)) + + got, err := resolveKeyMaterial("", path) + require.NoError(err) + require.Equal(want, got) + }) + + t.Run("missing file", func(t *testing.T) { + _, err := resolveKeyMaterial("", filepath.Join(t.TempDir(), "does-not-exist.pub")) + assert.Error(t, err) + }) +} + +func TestCreateKeyOptionConstruction(t *testing.T) { + tests := []struct { + name string + title string + key string + readOnly bool + }{ + {"read-only ed25519", "ci", "ssh-ed25519 AAAA...", true}, + {"read-write rsa", "deploy-bot", "ssh-rsa AAAA...", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opt := gitea.CreateKeyOption{ + Title: tt.title, + Key: tt.key, + ReadOnly: tt.readOnly, + } + assert.Equal(t, tt.title, opt.Title) + assert.Equal(t, tt.key, opt.Key) + assert.Equal(t, tt.readOnly, opt.ReadOnly) + }) + } +} diff --git a/cmd/deploykeys/delete.go b/cmd/deploykeys/delete.go new file mode 100644 index 00000000..770bf478 --- /dev/null +++ b/cmd/deploykeys/delete.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploykeys + +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" +) + +// CmdDeployKeysDelete represents a sub command of deploy-keys to delete a deploy key +var CmdDeployKeysDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a deploy key", + Description: "Delete a deploy key by ID from a repository", + ArgsUsage: "<key-id>", + Action: runDeployKeysDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runDeployKeysDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return errors.New("deploy key 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 key ID: %w", err) + } + + key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID) + if err != nil { + return err + } + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete deploy key %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 key %d deleted successfully\n", keyID) + return nil +} diff --git a/cmd/deploykeys/delete_test.go b/cmd/deploykeys/delete_test.go new file mode 100644 index 00000000..02439d82 --- /dev/null +++ b/cmd/deploykeys/delete_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploykeys + +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 := &CmdDeployKeysDelete + + assert.Equal(t, "delete", cmd.Name) + assert.Contains(t, cmd.Aliases, "rm") + assert.Equal(t, "Delete a deploy key", cmd.Usage) + assert.Equal(t, "Delete a deploy key by ID from a repository", cmd.Description) + assert.Equal(t, "<key-id>", cmd.ArgsUsage) + assert.NotNil(t, cmd.Action) +} + +func TestDeleteCommandFlags(t *testing.T) { + cmd := &CmdDeployKeysDelete + + 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 TestDeleteKeyIDParsing(t *testing.T) { + // Mirrors utils.ArgToIndex behavior, exercised through the same parsing + // the command would receive. + 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"} + + // The prompt must include both the ID and the title so the user can + // confirm which key is being deleted. + msg := "Are you sure you want to delete deploy key " + 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]") +} diff --git a/cmd/deploykeys/list.go b/cmd/deploykeys/list.go new file mode 100644 index 00000000..35d483ce --- /dev/null +++ b/cmd/deploykeys/list.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploykeys + +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" +) + +// CmdDeployKeysList represents a sub command of deploy-keys to list deploy keys +var CmdDeployKeysList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List deploy keys", + Description: "List deploy keys of a repository", + Action: RunDeployKeysList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + &cli.StringFlag{ + Name: "fingerprint", + Usage: "filter by fingerprint", + }, + }, flags.AllDefaultFlags...), +} + +// RunDeployKeysList lists deploy keys +func RunDeployKeysList(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), + } + if fp := cmd.String("fingerprint"); fp != "" { + opts.Fingerprint = fp + } + + keys, _, err := client.ListDeployKeys(ctx, c.Owner, c.Repo, opts) + if err != nil { + return err + } + + print.DeployKeysList(keys, c.Output) + return nil +} diff --git a/cmd/deploykeys/list_test.go b/cmd/deploykeys/list_test.go new file mode 100644 index 00000000..c2bd5f92 --- /dev/null +++ b/cmd/deploykeys/list_test.go @@ -0,0 +1,132 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package deploykeys + +import ( + "testing" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" +) + +func TestListCommandMetadata(t *testing.T) { + cmd := &CmdDeployKeysList + + assert.Equal(t, "list", cmd.Name) + assert.Contains(t, cmd.Aliases, "ls") + assert.Equal(t, "List deploy keys", cmd.Usage) + assert.Equal(t, "List deploy keys of a repository", cmd.Description) + assert.NotNil(t, cmd.Action) +} + +func TestListCommandFlags(t *testing.T) { + cmd := &CmdDeployKeysList + + expectedFlags := []string{ + "page", + "limit", + "fingerprint", + "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 + fingerprint string + page int + limit int + }{ + { + name: "default options", + fingerprint: "", + page: 1, + limit: 30, + }, + { + name: "with fingerprint filter", + fingerprint: "SHA256:abc123", + page: 1, + limit: 30, + }, + { + name: "with custom pagination", + fingerprint: "", + 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, + }, + } + if tt.fingerprint != "" { + opts.Fingerprint = tt.fingerprint + } + assert.Equal(t, tt.page, opts.Page) + assert.Equal(t, tt.limit, opts.PageSize) + assert.Equal(t, tt.fingerprint, opts.Fingerprint) + }) + } +} + +func TestListCommandStructure(t *testing.T) { + cmd := &CmdDeployKeysList + + 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) { + // Same output formats as the rest of tea (see flags.OutputFlag) + 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) { + // Headers must match the columns emitted by print.DeployKeysList. + expectedHeaders := []string{ + "ID", + "Title", + "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/docs/CLI.md b/docs/CLI.md index 2363574b..6a0fb0d2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2015,6 +2015,70 @@ Delete one or more comments by ID **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +## deploy-keys, dk, deploy-key + +Manage repository deploy keys + +**--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 + +### list, ls + +List deploy keys + +**--fingerprint**="": filter by fingerprint + +**--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, add, c + +Create a deploy key + +**--key**="": inline armored SSH public key + +**--key-file**="": path to a file containing the armored SSH public key (e.g. ~/.ssh/id_ed25519.pub) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--read-only**: restrict the key to read-only access (default) + +**--read-write**: grant the key read/write access + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### delete, rm + +Delete a deploy key + +**--confirm, -y**: confirm deletion without prompting + +**--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 + ## open, o Open something of the repository in web browser diff --git a/modules/print/deploy_key.go b/modules/print/deploy_key.go new file mode 100644 index 00000000..3be86052 --- /dev/null +++ b/modules/print/deploy_key.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" +) + +// DeployKeysList prints a listing of deploy keys +func DeployKeysList(keys []*gitea.DeployKey, output string) { + t := tableWithHeader( + "ID", + "Title", + "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.Fingerprint, + readOnly, + FormatTime(key.Created, false), + ) + } + + t.print(output) +} + +// DeployKeyDetails prints detailed information about a deploy key +func DeployKeyDetails(key *gitea.DeployKey) { + fmt.Printf("# Deploy Key %d\n\n", key.ID) + fmt.Printf("- **Title**: %s\n", key.Title) + fmt.Printf("- **Key ID**: %d\n", key.KeyID) + 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.URL != "" { + fmt.Printf("- **URL**: %s\n", key.URL) + } +}