mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)
- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated. - **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes. ## Breaking changes This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement. ## Issues fixes #1118 fixes #1116 fixes #1115 fixes #1094 fixes #1091 fixes #1073 fixes #1052 fixes #1032 fixes #1023 fixes #966 fixes #908 fixes #833 fixes #831 fixes #817 fixes #804 fixes #779 fixes #770 fixes #737 fixes #697 fixes #726 fixes #677 fixes #672 fixes #633 fixes #632 fixes #560 fixes #556 fixes #523 fixes #516 fixes #510 fixes #504 fixes #500 fixes #482 fixes #406 fixes #403 fixes #373 fixes #351 fixes #344 fixes #326 fixes #325 fixes #323 fixes #246 fixes #240 fixes #232 fixes #197 fixes #172 fixes #141 fixes #69 fixes #1110 fixes #569 fixes #480 fixes #666 fixes #670 fixes #457 fixes #643 fixes #498 fixes #490 fixes #1069 fixes #834 fixes #602 fixes #328 fixes #417 fixes #571 fixes #1029 fixes #489 fixes #1050 fixes #162 fixes #430 fixes #1087 fixes #683 Reviewed-on: https://gitea.com/gitea/tea/pulls/1126 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
12e415a7a7
commit
f02245449b
456 files changed
+18892
-34015
No files matched your search
@@ -0,0 +1,292 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/internal/git"
|
||||
"gitea.dev/tea/internal/services"
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// giteaTarget is the disposable instance configured for TestGitea* integration tests.
|
||||
type giteaTarget struct{ url, token string }
|
||||
|
||||
// giteaDeps skips unless a disposable Gitea instance is configured and returns
|
||||
// dependencies authenticated against it that run the system Git binary.
|
||||
func giteaDeps(t *testing.T) (giteaTarget, Dependencies, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
instance, tokenFile := os.Getenv("GITEA_TEA_TEST_URL"), os.Getenv("GITEA_TEA_TEST_TOKEN_FILE")
|
||||
if instance == "" || tokenFile == "" {
|
||||
t.Skip("disposable Gitea environment is not configured")
|
||||
}
|
||||
gitea := giteaTarget{url: instance, token: readTokenFile(t, tokenFile)}
|
||||
deps, out, diagnostics := testDeps(t)
|
||||
deps.Git = nil
|
||||
deps.Getenv = gitea.env(gitea.token)
|
||||
return gitea, deps, out, diagnostics
|
||||
}
|
||||
|
||||
func (g giteaTarget) env(token string) func(string) string {
|
||||
return envFunc(map[string]string{"TEA_HOST": g.url, "GITEA_TOKEN": token})
|
||||
}
|
||||
|
||||
func readTokenFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
token := strings.TrimSpace(string(data))
|
||||
require.NotEmpty(t, token)
|
||||
return token
|
||||
}
|
||||
|
||||
// outputExecutor runs commands with the current value of *deps and returns their
|
||||
// standard output, requiring success without diagnostics.
|
||||
func outputExecutor(t *testing.T, deps *Dependencies, out, diagnostics *bytes.Buffer) func(args ...string) []byte {
|
||||
return func(args ...string) []byte {
|
||||
t.Helper()
|
||||
out.Reset()
|
||||
diagnostics.Reset()
|
||||
require.NoError(t, Execute(context.Background(), *deps, args))
|
||||
require.Empty(t, diagnostics.String())
|
||||
return bytes.Clone(out.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// withEnv overlays vars on an existing environment lookup.
|
||||
func withEnv(getenv func(string) string, vars map[string]string) func(string) string {
|
||||
return func(key string) string {
|
||||
if value, ok := vars[key]; ok {
|
||||
return value
|
||||
}
|
||||
return getenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyResourceBrowser(t *testing.T, deps Dependencies, resource string, args ...string) {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, resource)
|
||||
browser := &recordingBrowser{}
|
||||
deps.Browser = browser
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
|
||||
require.NoError(t, Execute(context.Background(), deps, append(args, "--web", "--no-input")))
|
||||
require.Equal(t, resource, browser.resource)
|
||||
require.Empty(t, out.String())
|
||||
require.Empty(t, diagnostics.String())
|
||||
}
|
||||
|
||||
func verifyEditorSubmission(t *testing.T, deps Dependencies, repository, number string, review bool) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Log("POSIX editor integration fixture is unavailable on Windows")
|
||||
return
|
||||
}
|
||||
directory := t.TempDir()
|
||||
program := filepath.Join(directory, "editor with spaces")
|
||||
require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\nprintf 'Editor integration body' > \"$1\"\n"), 0o700))
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
|
||||
deps.Editor = terminal.SystemEditor{Directory: directory, Streams: deps.Streams}
|
||||
deps.Getenv = withEnv(deps.Getenv, map[string]string{"TEA_EDITOR": shellquote.Join(program)})
|
||||
args := []string{"issue", "comment", "create", number}
|
||||
if review {
|
||||
args = []string{"pr", "review", number, "--event", "PENDING"}
|
||||
}
|
||||
require.NoError(t, Execute(context.Background(), deps, append(args, "--repo", repository, "--editor", "--no-input", "--json")))
|
||||
var result struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
|
||||
require.Equal(t, "Editor integration body", result.Body)
|
||||
require.Positive(t, result.ID)
|
||||
defer func() {
|
||||
args := []string{"issue", "comment", "delete", fmt.Sprint(result.ID)}
|
||||
if review {
|
||||
args = []string{"pr", "review", "delete", number, fmt.Sprint(result.ID)}
|
||||
}
|
||||
if err := Execute(context.Background(), deps, append(args, "--repo", repository, "--yes")); err != nil {
|
||||
t.Errorf("editor submission cleanup: %v", err)
|
||||
}
|
||||
}()
|
||||
files, err := filepath.Glob(filepath.Join(directory, "tea-draft-*.md"))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, files)
|
||||
require.Empty(t, diagnostics.String())
|
||||
}
|
||||
|
||||
func verifyBodyEditorIntegration(t *testing.T, deps Dependencies, group, repository, number, previous string) {
|
||||
t.Helper()
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
|
||||
base := []string{group, "edit", number, "--repo", repository, "--no-input"}
|
||||
require.NoError(t, Execute(context.Background(), deps, append(base, "--body", "Original editor body")))
|
||||
defer func() {
|
||||
if err := Execute(context.Background(), deps, append(base, "--body", previous)); err != nil {
|
||||
t.Errorf("editor body restoration: %v", err)
|
||||
}
|
||||
}()
|
||||
editor, path := newDraftEditor(t, "Edited server body")
|
||||
deps.Editor = editor
|
||||
deps.Getenv = withEnv(deps.Getenv, map[string]string{"TEA_EDITOR": "integration-editor"})
|
||||
out.Reset()
|
||||
require.NoError(t, Execute(context.Background(), deps, append(base, "--editor", "--json=body")))
|
||||
require.Equal(t, "Original editor body", editor.initial)
|
||||
require.JSONEq(t, `{"body":"Edited server body"}`, out.String())
|
||||
requireDraft(t, path, false)
|
||||
require.Empty(t, diagnostics.String())
|
||||
}
|
||||
|
||||
func executeWithEditorIntegration(t *testing.T, deps Dependencies, initial, body string, args ...string) []byte {
|
||||
t.Helper()
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
|
||||
editor, path := newDraftEditor(t, body)
|
||||
deps.Editor = editor
|
||||
deps.Getenv = withEnv(deps.Getenv, map[string]string{"TEA_EDITOR": "integration-editor"})
|
||||
require.NoError(t, Execute(context.Background(), deps, append(args, "--editor", "--no-input", "--json")))
|
||||
require.Equal(t, initial, editor.initial)
|
||||
require.Equal(t, "integration-editor", editor.command)
|
||||
requireDraft(t, path, false)
|
||||
require.Empty(t, diagnostics.String())
|
||||
return bytes.Clone(out.Bytes())
|
||||
}
|
||||
|
||||
func verifyIssueAttachmentLifecycle(t *testing.T, deps Dependencies, group, repository, number string) {
|
||||
t.Helper()
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
|
||||
run := outputExecutor(t, &deps, out, diagnostics)
|
||||
execute := func(args ...string) []byte {
|
||||
t.Helper()
|
||||
return run(slices.Concat([]string{group, "attachment"}, args, []string{"--repo", repository, "--no-input", "--json"})...)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "attachment.txt")
|
||||
require.NoError(t, os.WriteFile(path, []byte("Disposable attachment content\n"), 0o600))
|
||||
var item services.IssueAttachment
|
||||
require.NoError(t, json.Unmarshal(execute("upload", number, path), &item))
|
||||
require.Positive(t, item.ID)
|
||||
require.Equal(t, "attachment.txt", item.Name)
|
||||
require.EqualValues(t, 30, item.Size)
|
||||
id := fmt.Sprint(item.ID)
|
||||
downloaded := filepath.Join(t.TempDir(), "downloaded.txt")
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "download", number, id, "--repo", repository, "--no-input", "--output-file", downloaded}))
|
||||
content, err := os.ReadFile(downloaded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Disposable attachment content\n", string(content))
|
||||
info, err := os.Stat(downloaded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
deleted := false
|
||||
defer func() {
|
||||
if !deleted {
|
||||
if err := Execute(context.Background(), deps, []string{group, "attachment", "delete", number, id, "--repo", repository, "--yes", "--no-input"}); err != nil {
|
||||
t.Errorf("attachment cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
require.NoError(t, json.Unmarshal(execute("edit", number, id, "--name", "renamed.txt"), &item))
|
||||
require.Equal(t, "renamed.txt", item.Name)
|
||||
require.NoError(t, json.Unmarshal(execute("view", number, id), &item))
|
||||
require.Equal(t, "renamed.txt", item.Name)
|
||||
var items []services.IssueAttachment
|
||||
require.NoError(t, json.Unmarshal(execute("list", number), &items))
|
||||
require.Len(t, items, 1)
|
||||
require.Equal(t, item.ID, items[0].ID)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "delete", number, id, "--repo", repository, "--yes", "--no-input"}))
|
||||
deleted = true
|
||||
require.JSONEq(t, `[]`, string(execute("list", number)))
|
||||
}
|
||||
|
||||
func verifySSHCloneAndCheckout(t *testing.T, deps Dependencies, repo services.Repository, pull services.Pull, user string, environment []string, endpoint string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("SSH fixture uses a POSIX wrapper")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
block, err := ssh.MarshalPrivateKey(private, "disposable Tea SSH fixture")
|
||||
require.NoError(t, err)
|
||||
keyFile := filepath.Join(directory, "key with spaces")
|
||||
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(block), 0o600))
|
||||
publicKey, err := ssh.NewPublicKey(public)
|
||||
require.NoError(t, err)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Streams.Out = out
|
||||
deps.Streams.In = bytes.NewReader(ssh.MarshalAuthorizedKey(publicKey))
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"ssh-key", "create", "-", "--title", "Disposable SSH transport", "--json"}))
|
||||
var registered services.SSHKey
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), ®istered))
|
||||
t.Cleanup(func() {
|
||||
if err := Execute(context.Background(), deps, []string{"ssh-key", "delete", fmt.Sprint(registered.ID), "--yes"}); err != nil {
|
||||
t.Errorf("SSH key cleanup: %v", err)
|
||||
}
|
||||
})
|
||||
host, port, err := net.SplitHostPort(endpoint)
|
||||
require.NoError(t, err)
|
||||
scan := exec.CommandContext(context.Background(), "ssh-keyscan", "-4", "-T", "5", "-p", port, host)
|
||||
known, err := scan.Output()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, known)
|
||||
knownFile := filepath.Join(directory, "known_hosts")
|
||||
require.NoError(t, os.WriteFile(knownFile, known, 0o600))
|
||||
realSSH, err := exec.LookPath("ssh")
|
||||
require.NoError(t, err)
|
||||
wrapper := "#!/bin/sh\nexec " + shellquote.Join(realSSH, "-4", "-oUserKnownHostsFile="+knownFile, "-oStrictHostKeyChecking=yes") + " \"$@\"\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(directory, "ssh"), []byte(wrapper), 0o700))
|
||||
env := []string{}
|
||||
for _, value := range environment {
|
||||
key, _, _ := strings.Cut(value, "=")
|
||||
if key != "PATH" && key != "GIT_SSH_COMMAND" && key != "GIT_SSH" {
|
||||
env = append(env, value)
|
||||
}
|
||||
}
|
||||
env = append(env, "PATH="+directory+string(os.PathListSeparator)+os.Getenv("PATH"), "GIT_SSH_VARIANT=ssh")
|
||||
deps.Git = git.System{Env: env, ErrOut: deps.Streams.ErrOut}
|
||||
instance := deps.Getenv("TEA_HOST")
|
||||
require.NoError(t, deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
|
||||
h := s.Hosts.Hosts[instance]
|
||||
h.SSHHost, h.GitProtocol = endpoint, "ssh"
|
||||
account := h.Users[user]
|
||||
account.SSHKey, account.SSHAgent = keyFile, false
|
||||
h.Users[user] = account
|
||||
s.Hosts.Hosts[instance] = h
|
||||
return nil
|
||||
}))
|
||||
clone := filepath.Join(directory, "private-clone")
|
||||
out.Reset()
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", repo.FullName, clone, "--no-input", "--json=id"}))
|
||||
require.JSONEq(t, fmt.Sprintf(`{"id":%d}`, repo.ID), out.String())
|
||||
out.Reset()
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", fmt.Sprint(pull.Number), "--repo", repo.FullName, "--cwd", clone, "--detach", "--no-input", "--json"}))
|
||||
var checkout git.CheckoutResult
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
|
||||
require.Equal(t, pull.HeadSHA, checkout.Commit)
|
||||
require.True(t, checkout.Detached)
|
||||
_, err = deps.Git.Output(context.Background(), clone, false, "config", "--local", "--get", "core.sshCommand")
|
||||
var missing *git.CommandError
|
||||
require.ErrorAs(t, err, &missing)
|
||||
require.Equal(t, 1, missing.ExitCode)
|
||||
}
|
||||
Reference in new issue
Block a user