revert: tea 1.0 rewrite (#1122–#1126) (#1146)

Reverts #1126, #1125, #1124, #1123 and #1122.

`go.mod` keeps the Go 1.26 line with toolchain `go1.26.9` (instead of the original `go1.26.6`) so the security fix from #1131 isn't lost.

reopens #69
reopens #141
reopens #162
reopens #172
reopens #197
reopens #232
reopens #240
reopens #246
reopens #323
reopens #325
reopens #326
reopens #328
reopens #344
reopens #351
reopens #373
reopens #403
reopens #406
reopens #417
reopens #430
reopens #457
reopens #480
reopens #482
reopens #489
reopens #490
reopens #498
reopens #500
reopens #504
reopens #510
reopens #516
reopens #523
reopens #556
reopens #560
reopens #569
reopens #571
reopens #602
reopens #632
reopens #633
reopens #643
reopens #666
reopens #670
reopens #672
reopens #677
reopens #683
reopens #697
reopens #726
reopens #737
reopens #770
reopens #779
reopens #804
reopens #817
reopens #831
reopens #833
reopens #834
reopens #908
reopens #966
reopens #1023
reopens #1029
reopens #1032
reopens #1050
reopens #1052
reopens #1069
reopens #1073
reopens #1087
reopens #1091
reopens #1094
reopens #1110
reopens #1115
reopens #1116
reopens #1118

Reviewed-on: https://gitea.com/gitea/tea/pulls/1146
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-18 20:56:33 +00:00
1 parent 33bb12729e
commit c5f674111a
604 files changed
+33711 -35545

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"errors"
"fmt"
"os"
"strings"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/git"
"gitea.dev/tea/modules/theme"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
var errNotAGiteaRepo = errors.New("no Gitea login found; you might want to specify --repo (and --login) to work outside of a repository")
// ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt.
var ErrCommandCanceled = errors.New("command canceled")
// TeaContext contains all context derived during command initialization and wraps cli.Context
type TeaContext struct {
*cli.Command
Login *config.Login // config data & client for selected login
RepoSlug string // <owner>/<repo>, optional
Owner string // repo owner as derived from context or provided in flag, optional
Repo string // repo name as derived from context or provided in flag, optional
Org string // organization name, optional
IsGlobal bool // true if operating on global level
Output string // value of output flag
LocalRepo *git.TeaRepo // is set if flags specified a local repo via --repo, or if $PWD is a git repo
}
// InitOptions controls which optional sources InitCommand may inspect.
type InitOptions struct {
// SkipLocalRepo avoids probing the current directory for a git repository.
SkipLocalRepo bool
}
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
// after ensuring a remote repo is present in the context.
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
if err := ctx.Ensure(CtxRequirement{RemoteRepo: true}); err != nil {
return "", err
}
return strings.TrimRight(ctx.Login.URL, "/") + "/" + ctx.Owner + "/" + ctx.Repo, nil
}
// IsInteractiveMode returns true if the command is running in interactive mode
// (no flags provided and stdout is a terminal)
func (ctx *TeaContext) IsInteractiveMode() bool {
return ctx.Command.NumFlags() == 0
}
func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool {
return login != nil && !login.Default && canPrompt
}
// InitCommand resolves the application context, and returns the active login, and if
// available the repo slug. It does this by reading the config file for logins, parsing
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
// command flags. If a local git repo can't be found, repo slug values are unset.
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
return InitCommandWithOptions(cmd, InitOptions{})
}
// InitCommandWithOptions resolves the application context like InitCommand, with
// optional controls for commands that do not need repository context.
func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, error) {
// these flags are used as overrides to the context detection via local git repo
repoFlag := cmd.String("repo")
loginFlag := cmd.String("login")
remoteFlag := cmd.String("remote")
orgFlag := cmd.String("org")
globalFlag := cmd.Bool("global")
var (
c TeaContext
err error
repoPath string // empty means PWD
repoFlagPathExists bool
)
// check if repoFlag can be interpreted as path to local repo.
if len(repoFlag) != 0 && !opts.SkipLocalRepo {
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
return nil, err
}
if repoFlagPathExists {
repoPath = repoFlag
} else {
c.RepoSlug = repoFlag
}
} else if len(repoFlag) != 0 {
c.RepoSlug = repoFlag
}
if len(remoteFlag) == 0 {
remoteFlag = config.GetPreferences().FlagDefaults.Remote
}
// Create env login before repo context detection so it participates in remote URL matching
var extraLogins []config.Login
envLogin := GetLoginByEnvVar()
if envLogin != nil {
if _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", ""); err != nil {
return nil, err
}
extraLogins = append(extraLogins, *envLogin)
}
if !opts.SkipLocalRepo {
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
// otherwise attempt PWD. if no repo is found, continue with default login
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
}
var localSlug string
if c.LocalRepo, c.Login, localSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == git.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
return nil, err
}
}
if c.RepoSlug == "" && localSlug != "" {
c.RepoSlug = localSlug
}
}
// If env vars are set, always use the env login (but repo slug was already
// resolved by contextFromLocalRepo with the env login in the match list)
if envLogin != nil {
c.Login = envLogin
}
// override login from flag, or use default login if repo based detection failed
if len(loginFlag) != 0 {
if c.Login, err = config.GetLoginByName(loginFlag); err != nil {
return nil, err
}
if c.Login == nil {
return nil, fmt.Errorf("login name '%s' does not exist", loginFlag)
}
} else if c.Login == nil {
if c.Login, err = config.GetDefaultLogin(); err != nil {
if err.Error() == "No available login" {
return nil, fmt.Errorf(`no gitea login configured. To start using tea, first run
tea login add
and then run your command again`)
}
return nil, err
}
// Only prompt for confirmation if the fallback login is not explicitly set as default
canPrompt := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
if shouldPromptFallbackLogin(c.Login, canPrompt) {
fallback := false
if err := huh.NewConfirm().
Title(fmt.Sprintf("NOTE: no login matched this repository. Fall back to login '%s'?", c.Login.Name)).
Value(&fallback).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, fmt.Errorf("get confirm failed: %w", err)
}
if !fallback {
return nil, ErrCommandCanceled
}
} else if !c.Login.Default {
fmt.Fprintf(os.Stderr, "NOTE: no login matched this repository, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
}
}
// parse reposlug (owner falling back to login owner if reposlug contains only repo name)
c.Owner, c.Repo = utils.GetOwnerAndRepo(c.RepoSlug, c.Login.User)
c.Org = orgFlag
c.IsGlobal = globalFlag
c.Command = cmd
c.Output = cmd.String("output")
return &c, nil
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"os"
"strconv"
"time"
"gitea.dev/tea/modules/config"
)
// GetLoginByEnvVar returns a login based on environment variables, or nil if no login can be created
func GetLoginByEnvVar() *config.Login {
var token string
giteaToken := os.Getenv("GITEA_TOKEN")
githubToken := os.Getenv("GH_TOKEN")
giteaInstanceURL := os.Getenv("GITEA_INSTANCE_URL")
giteaInstanceSSHHost := os.Getenv("GITEA_INSTANCE_SSH_HOST")
instanceInsecure := os.Getenv("GITEA_INSTANCE_INSECURE")
insecure := false
if len(instanceInsecure) > 0 {
insecure, _ = strconv.ParseBool(instanceInsecure)
}
// if no tokens are set, or no instance url for gitea fail fast
if len(giteaInstanceURL) == 0 || (len(giteaToken) == 0 && len(githubToken) == 0) {
return nil
}
token = giteaToken
if len(giteaToken) == 0 {
token = githubToken
}
return &config.Login{
Name: "GITEA_LOGIN_VIA_ENV",
URL: giteaInstanceURL,
Token: token,
SSHHost: giteaInstanceSSHHost,
Insecure: insecure,
SSHKey: "",
SSHCertPrincipal: "",
SSHKeyFingerprint: "",
SSHAgent: false,
Created: time.Now().Unix(),
VersionCheck: false,
}
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"testing"
"gitea.dev/tea/modules/config"
)
func TestShouldPromptFallbackLogin(t *testing.T) {
tests := []struct {
name string
login *config.Login
canPrompt bool
expected bool
}{
{
name: "no login",
login: nil,
canPrompt: true,
expected: false,
},
{
name: "default login",
login: &config.Login{Default: true},
canPrompt: true,
expected: false,
},
{
name: "non-default no prompt",
login: &config.Login{Default: false},
canPrompt: false,
expected: false,
},
{
name: "non-default prompt",
login: &config.Login{Default: false},
canPrompt: true,
expected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := shouldPromptFallbackLogin(test.login, test.canPrompt); got != test.expected {
t.Fatalf("expected %v, got %v", test.expected, got)
}
})
}
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"strings"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/debug"
"gitea.dev/tea/modules/git"
)
// MatchLogins matches the given remoteURL against the provided logins and returns
// the first matching login
// remoteURL could be like:
//
// https://gitea.com/owner/repo.git
// http://gitea.com/owner/repo.git
// ssh://gitea.com/owner/repo.git
// git@gitea.com:owner/repo.git
func MatchLogins(remoteURL string, logins []config.Login) (*config.Login, string, error) {
for _, l := range logins {
debug.Printf("Matching remote URL '%s' against %v login", remoteURL, l)
sshHost := l.GetSSHHost()
atIdx := strings.Index(remoteURL, "@")
colonIdx := strings.Index(remoteURL, ":")
if atIdx > 0 && colonIdx > atIdx {
domain := remoteURL[atIdx+1 : colonIdx]
if domain == sshHost {
return &l, strings.TrimSuffix(remoteURL[colonIdx+1:], ".git"), nil
}
} else {
p, err := git.ParseURL(remoteURL)
if err != nil {
return nil, "", fmt.Errorf("git remote URL parse failed: %s", err.Error())
}
switch {
case strings.EqualFold(p.Scheme, "http") || strings.EqualFold(p.Scheme, "https"):
if strings.HasPrefix(remoteURL, l.URL) {
ps := strings.Split(p.Path, "/")
path := strings.Join(ps[len(ps)-2:], "/")
return &l, strings.TrimSuffix(path, ".git"), nil
}
case strings.EqualFold(p.Scheme, "ssh"):
if sshHost == p.Host || sshHost == p.Hostname() {
return &l, strings.TrimLeft(p.Path, "/"), nil
}
default:
// unknown scheme
return nil, "", fmt.Errorf("git remote URL parse failed: %s", "unknown scheme "+p.Scheme)
}
}
}
return nil, "", errNotAGiteaRepo
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/debug"
"gitea.dev/tea/modules/git"
)
// contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo
func contextFromLocalRepo(repoPath, remoteValue string, extraLogins []config.Login) (*git.TeaRepo, *config.Login, string, error) {
repo, err := git.RepoFromPath(repoPath)
if err != nil {
return nil, nil, "", err
}
gitConfig, err := repo.Config()
if err != nil {
return repo, nil, "", err
}
debug.Printf("Get git config %v of %s in repo %s", gitConfig, remoteValue, repoPath)
if len(gitConfig.Remotes) == 0 {
return repo, nil, "", errNotAGiteaRepo
}
// When no preferred value is given, choose a remote to find a
// matching login based on its URL.
if len(gitConfig.Remotes) > 1 && len(remoteValue) == 0 {
// if master branch is present, use it as the default remote
mainBranches := []string{"main", "master", "trunk"}
for _, b := range mainBranches {
masterBranch, ok := gitConfig.Branches[b]
if ok {
if len(masterBranch.Remote) > 0 {
remoteValue = masterBranch.Remote
}
break
}
}
// if no branch has matched, default to origin or upstream remote.
if len(remoteValue) == 0 {
if _, ok := gitConfig.Remotes["upstream"]; ok {
remoteValue = "upstream"
} else if _, ok := gitConfig.Remotes["origin"]; ok {
remoteValue = "origin"
}
}
}
// make sure a remote is selected
if len(remoteValue) == 0 {
for remote := range gitConfig.Remotes {
remoteValue = remote
break
}
}
remoteConfig, ok := gitConfig.Remotes[remoteValue]
if !ok || remoteConfig == nil {
return repo, nil, "", fmt.Errorf("remote '%s' not found in this Git repository", remoteValue)
}
debug.Printf("Get remote configurations %v of %s in repo %s", remoteConfig, remoteValue, repoPath)
logins, err := config.GetLogins()
if err != nil {
return repo, nil, "", err
}
// Prepend extra logins (e.g. from env vars) so they are matched first
if len(extraLogins) > 0 {
logins = append(extraLogins, logins...)
}
for _, u := range remoteConfig.URLs {
if l, p, err := MatchLogins(u, logins); err == nil {
return repo, l, p, nil
}
}
return repo, nil, "", errNotAGiteaRepo
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"errors"
)
// Ensure checks if requirements on the context are set.
func (ctx *TeaContext) Ensure(req CtxRequirement) error {
if req.LocalRepo && ctx.LocalRepo == nil {
return errors.New("local repository required: execute from a repo dir, or specify a path with --repo")
}
if req.RemoteRepo && len(ctx.RepoSlug) == 0 {
return errors.New("remote repository required: specify id via --repo or execute from a local git repo")
}
if req.Org && len(ctx.Org) == 0 {
return errors.New("organization required: specify organization via --org")
}
if req.Global && !ctx.IsGlobal {
return errors.New("global scope required: specify --global")
}
return nil
}
// CtxRequirement specifies context needed for operation
type CtxRequirement struct {
// ensures a local git repo is available & ctx.LocalRepo is set. Implies .RemoteRepo
LocalRepo bool
// ensures ctx.RepoSlug, .Owner, .Repo are set
RemoteRepo bool
// ensures ctx.Org is set
Org bool
// ensures ctx.IsGlobal is true
Global bool
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/git"
)
func TestEnsureReturnsRequirementErrors(t *testing.T) {
tests := []struct {
name string
ctx TeaContext
req CtxRequirement
wantErr string
}{
{
name: "missing local repo",
ctx: TeaContext{},
req: CtxRequirement{LocalRepo: true},
wantErr: "local repository required",
},
{
name: "missing remote repo",
ctx: TeaContext{},
req: CtxRequirement{RemoteRepo: true},
wantErr: "remote repository required",
},
{
name: "missing org",
ctx: TeaContext{},
req: CtxRequirement{Org: true},
wantErr: "organization required",
},
{
name: "missing global scope",
ctx: TeaContext{},
req: CtxRequirement{Global: true},
wantErr: "global scope required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.ctx.Ensure(tt.req)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestEnsureSucceedsWhenRequirementsMet(t *testing.T) {
ctx := TeaContext{
LocalRepo: &git.TeaRepo{},
RepoSlug: "owner/repo",
Owner: "owner",
Repo: "repo",
Org: "myorg",
IsGlobal: true,
}
err := ctx.Ensure(CtxRequirement{
LocalRepo: true,
RemoteRepo: true,
Org: true,
Global: true,
})
require.NoError(t, err)
}
func TestEnsureSucceedsWithNoRequirements(t *testing.T) {
ctx := TeaContext{}
err := ctx.Ensure(CtxRequirement{})
require.NoError(t, err)
}
func TestGetRemoteRepoHTMLURL(t *testing.T) {
t.Run("requires remote repo", func(t *testing.T) {
ctx := &TeaContext{}
_, err := ctx.GetRemoteRepoHTMLURL()
require.ErrorContains(t, err, "remote repository required")
})
t.Run("returns repo url when context is complete", func(t *testing.T) {
ctx := &TeaContext{
Login: &config.Login{URL: "https://gitea.example.com"},
RepoSlug: "owner/repo",
Owner: "owner",
Repo: "repo",
}
url, err := ctx.GetRemoteRepoHTMLURL()
require.NoError(t, err)
assert.Equal(t, "https://gitea.example.com/owner/repo", url)
})
t.Run("trims trailing slash from login URL", func(t *testing.T) {
ctx := &TeaContext{
Login: &config.Login{URL: "https://gitea.example.com/"},
RepoSlug: "owner/repo",
Owner: "owner",
Repo: "repo",
}
url, err := ctx.GetRemoteRepoHTMLURL()
require.NoError(t, err)
assert.Equal(t, "https://gitea.example.com/owner/repo", url)
})
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"testing"
"gitea.dev/tea/modules/config"
)
func Test_MatchLogins(t *testing.T) {
kases := []struct {
remoteURL string
logins []config.Login
matchedLoginName string
expectedRepoPath string
hasError bool
}{
{
remoteURL: "https://gitea.com/owner/repo.git",
logins: []config.Login{{Name: "gitea.com", URL: "https://gitea.com"}},
matchedLoginName: "gitea.com",
expectedRepoPath: "owner/repo",
hasError: false,
},
{
remoteURL: "git@gitea.com:owner/repo.git",
logins: []config.Login{{Name: "gitea.com", URL: "https://gitea.com"}},
matchedLoginName: "gitea.com",
expectedRepoPath: "owner/repo",
hasError: false,
},
{
remoteURL: "git@custom-ssh.example.com:owner/repo.git",
logins: []config.Login{{Name: "env", URL: "https://gitea.example.com", SSHHost: "custom-ssh.example.com"}},
matchedLoginName: "env",
expectedRepoPath: "owner/repo",
hasError: false,
},
{
remoteURL: "https://gitea.example.com/owner/repo.git",
logins: []config.Login{
{Name: "env", URL: "https://gitea.example.com"},
{Name: "config", URL: "https://gitea.example.com"},
},
matchedLoginName: "env",
expectedRepoPath: "owner/repo",
hasError: false,
},
}
for _, kase := range kases {
t.Run(kase.remoteURL, func(t *testing.T) {
login, repoPath, err := MatchLogins(kase.remoteURL, kase.logins)
if (err != nil) != kase.hasError {
t.Errorf("Expected error: %v, got: %v", kase.hasError, err)
}
if repoPath != kase.expectedRepoPath {
t.Errorf("Expected repo path: %s, got: %s", kase.expectedRepoPath, repoPath)
}
if !kase.hasError && login.Name != kase.matchedLoginName {
t.Errorf("Expected login name: %s, got: %s", kase.matchedLoginName, login.Name)
}
})
}
}