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:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

-104
View File
@@ -1,104 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package api
import (
"crypto/tls"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/httputil"
)
// Client provides direct HTTP access to Gitea API
type Client struct {
baseURL string
token string
httpClient *http.Client
}
// NewClient creates a new API client from a Login config
func NewClient(login *config.Login) *Client {
// Refresh OAuth token if expired or near expiry
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
log.Printf("Warning: failed to refresh OAuth token: %v", err)
}
httpClient := &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: login.Insecure}),
}
return &Client{
baseURL: strings.TrimSuffix(login.URL, "/"),
token: login.GetAccessToken(),
httpClient: httpClient,
}
}
// Do executes an HTTP request with authentication headers
func (c *Client) Do(method, endpoint string, body io.Reader, headers map[string]string) (*http.Response, error) {
// Build the full URL
reqURL, err := c.buildURL(endpoint)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, reqURL, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set authentication header
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
// Set default content type for requests with body
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Apply custom headers (can override defaults)
for key, value := range headers {
req.Header.Set(key, value)
}
return c.httpClient.Do(req)
}
// buildURL constructs the full URL from an endpoint
func (c *Client) buildURL(endpoint string) (string, error) {
// If endpoint is already a full URL, validate it matches the login's host
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
endpointURL, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
baseURL, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("invalid base URL: %w", err)
}
if endpointURL.Host != baseURL.Host {
return "", fmt.Errorf("URL host %q does not match login host %q (token would be sent to wrong server)", endpointURL.Host, baseURL.Host)
}
return endpoint, nil
}
// Ensure endpoint starts with /
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
// Auto-prefix /api/v1/ if not present
if !strings.HasPrefix(endpoint, "/api/") {
endpoint = "/api/v1" + endpoint
}
return c.baseURL + endpoint, nil
}
-461
View File
@@ -1,461 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/httputil"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/utils"
"github.com/skratchdot/open-golang/open"
"golang.org/x/oauth2"
)
// Constants for OAuth2 PKCE flow
const (
// default scopes to request
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
// length of code verifier
codeVerifierLength = 64
// timeout for oauth server response
authTimeout = 60 * time.Second
// local server settings to receive the callback
redirectPort = 0
redirectHost = "127.0.0.1"
)
// OAuthOptions contains options for the OAuth login flow
type OAuthOptions struct {
Name string
URL string
Insecure bool
ClientID string
RedirectURL string
Port int
}
// OAuthLogin performs an OAuth2 PKCE login flow to authorize the CLI
func OAuthLogin(ctx context.Context, name, giteaURL string) error {
return OAuthLoginWithOptions(ctx, name, giteaURL, false)
}
// OAuthLoginWithOptions performs an OAuth2 PKCE login flow with additional options
func OAuthLoginWithOptions(ctx context.Context, name, giteaURL string, insecure bool) error {
opts := OAuthOptions{
Name: name,
URL: giteaURL,
Insecure: insecure,
ClientID: config.DefaultClientID,
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
Port: redirectPort,
}
return OAuthLoginWithFullOptions(ctx, opts)
}
// OAuthLoginWithFullOptions performs an OAuth2 PKCE login flow with full options control
func OAuthLoginWithFullOptions(ctx context.Context, opts OAuthOptions) error {
serverURL, token, err := performBrowserOAuthFlow(ctx, opts)
if err != nil {
return err
}
return createLoginFromToken(ctx, opts.Name, serverURL, token, opts.Insecure)
}
// performBrowserOAuthFlow performs the browser-based OAuth2 PKCE flow and returns the token.
// This is the shared implementation used by both new logins and re-authentication.
func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL string, token *oauth2.Token, err error) {
// Normalize URL
normalizedURL, err := utils.NormalizeURL(opts.URL)
if err != nil {
return "", nil, fmt.Errorf("unable to parse URL: %s", err)
}
serverURL = normalizedURL.String()
// Set defaults if needed
if opts.ClientID == "" {
opts.ClientID = config.DefaultClientID
}
// If the redirect URL is specified, parse it to extract port if needed
if opts.RedirectURL != "" {
parsedURL, err := url.Parse(opts.RedirectURL)
if err == nil && parsedURL.Port() != "" {
port, err := strconv.Atoi(parsedURL.Port())
if err == nil {
opts.Port = port
}
}
} else {
// If no redirect URL, ensure we have a port and then set the default redirect URL
if opts.Port == 0 {
opts.Port = redirectPort
}
opts.RedirectURL = fmt.Sprintf("http://%s:%d", redirectHost, opts.Port)
}
// Double check that port is set
if opts.Port == 0 {
opts.Port = redirectPort
}
// Generate code verifier (random string)
codeVerifier, err := generateCodeVerifier(codeVerifierLength)
if err != nil {
return "", nil, fmt.Errorf("failed to generate code verifier: %s", err)
}
// Generate code challenge (SHA256 hash of code verifier)
codeChallenge := generateCodeChallenge(codeVerifier)
// Set up the OAuth2 config
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(opts.Insecure))
// Configure the OAuth2 endpoints
authURL := fmt.Sprintf("%s/login/oauth/authorize", normalizedURL)
tokenURL := fmt.Sprintf("%s/login/oauth/access_token", normalizedURL)
oauth2Config := &oauth2.Config{
ClientID: opts.ClientID,
ClientSecret: "", // No client secret for PKCE
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
RedirectURL: opts.RedirectURL,
Scopes: strings.Split(defaultScopes, ","),
}
// Set up PKCE extension options
authCodeOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
}
// Generate state parameter to protect against CSRF
state, err := generateCodeVerifier(32)
if err != nil {
return "", nil, fmt.Errorf("failed to generate state: %s", err)
}
// Get the authorization URL
authCodeURL := oauth2Config.AuthCodeURL(state, authCodeOpts...)
// Start a local server to receive the callback. When opts.Port == 0,
// this resolves opts.RedirectURL to the actual listener port; mirror
// that into oauth2Config so Exchange sends the same redirect_uri the
// authorize step advertised (RFC 6749 §4.1.3).
code, receivedState, err := startLocalServerAndOpenBrowser(authCodeURL, state, &opts)
oauth2Config.RedirectURL = opts.RedirectURL
if err != nil {
// Check for redirect URI errors
if strings.Contains(err.Error(), "no authorization code") ||
strings.Contains(err.Error(), "redirect_uri") ||
strings.Contains(err.Error(), "redirect") {
fmt.Println("\nError: Redirect URL not registered in Gitea")
fmt.Println("\nTo fix this, you need to register the redirect URL in Gitea:")
fmt.Printf("1. Go to your Gitea instance: %s\n", normalizedURL)
fmt.Println("2. Sign in and go to Settings > Applications")
fmt.Println("3. Register a new OAuth2 application with:")
fmt.Printf(" - Application Name: tea-cli (or any name)\n")
fmt.Printf(" - Redirect URI: %s\n", opts.RedirectURL)
fmt.Println("4. Copy the Client ID and try again with:")
fmt.Printf(" tea login add --oauth --client-id YOUR_CLIENT_ID --redirect-url %s\n", opts.RedirectURL)
fmt.Println("\nAlternatively, you can use a token-based login: tea login add")
}
return "", nil, fmt.Errorf("authorization failed: %s", err)
}
// Verify state to prevent CSRF attacks
if state != receivedState {
return "", nil, fmt.Errorf("state mismatch, possible CSRF attack")
}
// Exchange authorization code for token
token, err = oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
if err != nil {
return "", nil, fmt.Errorf("token exchange failed: %s", err)
}
return serverURL, token, nil
}
// createHTTPClient creates an HTTP client with optional insecure setting
func createHTTPClient(insecure bool) *http.Client {
return &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: insecure}),
}
}
// generateCodeVerifier creates a cryptographically random string for PKCE
func generateCodeVerifier(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil
}
// generateCodeChallenge creates a code challenge from the code verifier using SHA256
func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// startLocalServerAndOpenBrowser starts a local HTTP server to receive the
// OAuth callback and opens the browser to the authorization URL. If
// opts.Port is 0, the listener picks a free port and opts.RedirectURL is
// rewritten in place with that port.
func startLocalServerAndOpenBrowser(authURL, expectedState string, opts *OAuthOptions) (string, string, error) {
// Channel to receive the authorization code
codeChan := make(chan string, 1)
stateChan := make(chan string, 1)
errChan := make(chan error, 1)
// Parse the redirect URL to get the path
parsedURL, err := url.Parse(opts.RedirectURL)
if err != nil {
return "", "", fmt.Errorf("invalid redirect URL: %s", err)
}
// Path to listen for in the callback
callbackPath := parsedURL.Path
if callbackPath == "" {
callbackPath = "/"
}
// Get the hostname from the redirect URL
hostname := parsedURL.Hostname()
if hostname == "" {
hostname = redirectHost
}
// Ensure we have a valid port
port := opts.Port
if port == 0 {
if parsedPort := parsedURL.Port(); parsedPort != "" {
port, _ = strconv.Atoi(parsedPort)
}
}
// Server address with port (may be dynamic if port=0)
serverAddr := fmt.Sprintf("%s:%d", hostname, port)
// Start local server
server := &http.Server{
Addr: serverAddr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only process the callback path
if r.URL.Path != callbackPath {
http.NotFound(w, r)
return
}
// Extract code and state from URL parameters
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
error := r.URL.Query().Get("error")
errorDesc := r.URL.Query().Get("error_description")
if error != "" {
errMsg := error
if errorDesc != "" {
errMsg += ": " + errorDesc
}
errChan <- fmt.Errorf("authorization error: %s", errMsg)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Error: %s", errMsg)
return
}
if code == "" {
errChan <- fmt.Errorf("no authorization code received")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Error: No authorization code received")
return
}
// Send success response to browser
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Authorization successful! You can close this window and return to the CLI.")
// Send code to channel
codeChan <- code
stateChan <- state
}),
}
// Listener for getting the actual port when using port 0
listener, err := net.Listen("tcp", serverAddr)
if err != nil {
return "", "", fmt.Errorf("failed to start local server: %s", err)
}
// Get the actual port if we used port 0
if port == 0 {
addr := listener.Addr().(*net.TCPAddr)
port = addr.Port
// Update redirect URL with actual port
parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port)
opts.RedirectURL = parsedURL.String()
// Update the auth URL with the new redirect URL
authURLParsed, err := url.Parse(authURL)
if err == nil {
query := authURLParsed.Query()
query.Set("redirect_uri", opts.RedirectURL)
authURLParsed.RawQuery = query.Encode()
authURL = authURLParsed.String()
}
}
// Start server in a goroutine
go func() {
fmt.Printf("Starting local server on %s:%d...\n", hostname, port)
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
// Wait a bit for server to start
time.Sleep(100 * time.Millisecond)
// Open browser
fmt.Println("Opening browser for authorization...")
if err := openBrowser(authURL); err != nil {
fmt.Println("Failed to open browser: ", err)
}
// Wait for code, error, or timeout
select {
case code := <-codeChan:
state := <-stateChan
// Shut down server
go server.Close()
return code, state, nil
case err := <-errChan:
go server.Close()
return "", "", err
case <-time.After(authTimeout):
go server.Close()
return "", "", fmt.Errorf("authentication timed out after %s", authTimeout)
}
}
// openBrowser opens the default browser to the specified URL.
// It is a variable to allow tests to inject a fake browser.
var openBrowser = func(url string) error {
fmt.Printf("Please authorize the application by visiting this URL in your browser:\n%s\n", url)
// Don't wait for the opener to exit, so a browser that holds the
// foreground can't block the wait for the callback.
return open.Start(url)
}
// createLoginFromToken creates a login entry using the obtained access token
func createLoginFromToken(ctx context.Context, name, serverURL string, token *oauth2.Token, insecure bool) error {
if name == "" {
var err error
name, err = task.GenerateLoginName(serverURL, "")
if err != nil {
return err
}
}
// Create login object with OAuth auth method
login := config.Login{
Name: name,
URL: serverURL,
Token: token.AccessToken, // temporarily set for Client() validation
AuthMethod: config.AuthMethodOAuth,
Insecure: insecure,
VersionCheck: true,
Created: time.Now().Unix(),
}
// Validate token by getting user info
client := login.Client()
u, _, err := client.Users.GetMyUserInfo(ctx)
if err != nil {
return fmt.Errorf("failed to validate token: %s", err)
}
// Clear token from YAML fields (will be stored in credstore)
login.Token = ""
// Set user info
login.User = u.UserName
// Get SSH host
parsedURL, err := url.Parse(serverURL)
if err != nil {
return err
}
login.SSHHost = parsedURL.Host
// Save tokens and add login to config
if err := config.AddOAuthLogin(&login, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
return err
}
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
return nil
}
// RefreshAccessToken manually renews an access token using the refresh token.
// This is used by the "tea login oauth-refresh" command for explicit token refresh.
// For automatic threshold-based refresh, use login.Client() which handles it internally.
func RefreshAccessToken(login *config.Login) error {
return login.RefreshOAuthToken()
}
// ReauthenticateLogin performs a full browser-based OAuth flow to get new tokens
// for an existing login. This is used when the refresh token is expired or invalid.
func ReauthenticateLogin(ctx context.Context, login *config.Login) error {
opts := OAuthOptions{
Name: login.Name,
URL: login.URL,
Insecure: login.Insecure,
ClientID: config.DefaultClientID,
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
Port: redirectPort,
}
_, token, err := performBrowserOAuthFlow(ctx, opts)
if err != nil {
return err
}
if login.IsOAuth() {
return config.SaveOAuthTokenFromOAuth2(login.Name, token, login)
}
// Legacy path for non-OAuth logins
login.Token = token.AccessToken
if token.RefreshToken != "" {
login.RefreshToken = token.RefreshToken
}
if !token.Expiry.IsZero() {
login.TokenExpiry = token.Expiry.Unix()
}
return config.SaveLoginTokens(login)
}
-129
View File
@@ -1,129 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Regression test for the redirect_uri propagation bug: with --redirect-url
// omitted, the callback listener picks a free port and opts.RedirectURL is
// rewritten in place. The OAuth2 config must see that rewritten URL so the
// token exchange sends the same redirect_uri the authorize step advertised
// (RFC 6749 §4.1.3).
func TestPerformBrowserOAuthFlow_RedirectURIMatchesAcrossAuthorizeAndExchange(t *testing.T) {
var (
mu sync.Mutex
authorizeRedirectURI string
exchangeRedirectURI string
)
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/authorize":
mu.Lock()
authorizeRedirectURI = r.URL.Query().Get("redirect_uri")
mu.Unlock()
cb, err := url.Parse(r.URL.Query().Get("redirect_uri"))
require.NoError(t, err)
q := cb.Query()
q.Set("code", "test-code")
q.Set("state", r.URL.Query().Get("state"))
cb.RawQuery = q.Encode()
http.Redirect(w, r, cb.String(), http.StatusFound)
case "/login/oauth/access_token":
require.NoError(t, r.ParseForm())
mu.Lock()
exchangeRedirectURI = r.PostForm.Get("redirect_uri")
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"token_type": "Bearer",
"expires_in": 3600,
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(idp.Close)
// Replace the real browser opener with a goroutine that fetches the
// authorize URL, just like a real browser would.
origOpenBrowser := openBrowser
t.Cleanup(func() { openBrowser = origOpenBrowser })
openBrowser = func(authURL string) error {
go func() {
resp, err := http.Get(authURL)
if err == nil {
resp.Body.Close()
}
}()
return nil
}
// Leave RedirectURL empty so tea picks a random port.
_, token, err := performBrowserOAuthFlow(context.Background(), OAuthOptions{
URL: idp.URL,
ClientID: "test-client-id",
})
require.NoError(t, err)
require.Equal(t, "test-token", token.AccessToken)
mu.Lock()
defer mu.Unlock()
require.NotEmpty(t, authorizeRedirectURI)
require.NotEmpty(t, exchangeRedirectURI)
assert.Equal(t, authorizeRedirectURI, exchangeRedirectURI,
"redirect_uri must match between authorize and token exchange (RFC 6749 §4.1.3)")
}
// Regression test for the browser opener hang: xdg-open does not exit until
// the browser it launched does, and the callback is only consumed after
// openBrowser returns. Waiting on the opener hangs the CLI even though the
// user authenticated successfully.
func TestOpenBrowser_DoesNotWaitForOpener(t *testing.T) {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
t.Skip("xdg-open is not the opener on this platform")
}
const (
fakeOpenerSleepTime = 10 * time.Second
openBrowserTimeout = 2 * time.Second
)
// A stand-in xdg-open that holds the foreground the way a browser it had
// to launch would.
dir := t.TempDir()
opener := filepath.Join(dir, "xdg-open")
script := fmt.Sprintf("#!/bin/sh\nexec sleep %d\n", int(fakeOpenerSleepTime.Seconds()))
require.NoError(t, os.WriteFile(opener, []byte(script), 0o755))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
done := make(chan error, 1)
go func() { done <- openBrowser("http://127.0.0.1:1/") }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(openBrowserTimeout):
t.Fatal("openBrowser blocked on the opener; the callback would never be consumed")
}
}
-157
View File
@@ -1,157 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"gitea.dev/tea/modules/utils"
"github.com/adrg/xdg"
"gopkg.in/yaml.v3"
)
// FlagDefaults defines all flags that can be overridden with a default value
// via the config file
type FlagDefaults struct {
// Prefer a specific git remote to use for selecting a repository on gitea,
// instead of relying on the remote associated with main/master/trunk branch.
// The --remote flag still has precedence over this value.
Remote string `yaml:"remote"`
}
// Preferences that are stored in and read from the config file
type Preferences struct {
// Prefer using an external text editor over inline multiline prompts
Editor bool `yaml:"editor"`
FlagDefaults FlagDefaults `yaml:"flag_defaults"`
}
// LocalConfig represents local configurations
type LocalConfig struct {
Logins []Login `yaml:"logins"`
Prefs Preferences `yaml:"preferences"`
}
var (
// config contain if loaded local tea config
config LocalConfig
loadConfigOnce sync.Once
configPathMu sync.Mutex
configPathTestOverride string
)
// GetConfigPath return path to tea config file
func GetConfigPath() string {
configPathMu.Lock()
override := configPathTestOverride
configPathMu.Unlock()
if override != "" {
return override
}
configFilePath, err := xdg.ConfigFile("tea/config.yml")
var exists bool
if err != nil {
exists = false
} else {
exists, _ = utils.PathExists(configFilePath)
}
// fallback to old config if no new one exists
if !exists {
file := filepath.Join(xdg.Home, ".tea", "tea.yml")
exists, _ = utils.PathExists(file)
if exists {
return file
}
}
if err != nil {
fmt.Fprintln(os.Stderr, "unable to get or create config file")
os.Exit(1)
}
return configFilePath
}
// SetConfigPathForTesting overrides the config path used by helpers in tests.
func SetConfigPathForTesting(path string) {
configPathMu.Lock()
configPathTestOverride = path
configPathMu.Unlock()
}
// GetPreferences returns preferences based on the config file
func GetPreferences() Preferences {
_ = loadConfig()
return config.Prefs
}
// loadConfig load config from file
func loadConfig() (err error) {
loadConfigOnce.Do(func() {
ymlPath := GetConfigPath()
exist, _ := utils.FileExist(ymlPath)
if exist {
bs, readErr := os.ReadFile(ymlPath)
if readErr != nil {
err = fmt.Errorf("failed to read config file %s: %w", ymlPath, readErr)
return
}
if unmarshalErr := yaml.Unmarshal(bs, &config); unmarshalErr != nil {
err = fmt.Errorf("failed to parse config file %s: %w", ymlPath, unmarshalErr)
return
}
}
})
return
}
// reloadConfigFromDisk re-reads the config file from disk, bypassing the sync.Once.
// This is used after acquiring a lock to ensure we have the latest config state.
// The caller must hold the config lock.
func reloadConfigFromDisk() error {
ymlPath := GetConfigPath()
exist, _ := utils.FileExist(ymlPath)
if !exist {
// No config file yet, start with empty config
config = LocalConfig{}
return nil
}
bs, err := os.ReadFile(ymlPath)
if err != nil {
return fmt.Errorf("failed to read config file %s: %w", ymlPath, err)
}
if err := yaml.Unmarshal(bs, &config); err != nil {
return fmt.Errorf("failed to parse config file %s: %w", ymlPath, err)
}
return nil
}
// SetConfigForTesting replaces the in-memory config and marks it as loaded.
// This allows tests to inject config without relying on file-based loading.
func SetConfigForTesting(cfg LocalConfig) {
loadConfigOnce.Do(func() {}) // ensure sync.Once is spent
config = cfg
}
// saveConfigUnsafe saves config to file without acquiring a lock.
// Caller must hold the config lock.
func saveConfigUnsafe() error {
ymlPath := GetConfigPath()
bs, err := yaml.Marshal(config)
if err != nil {
return err
}
return os.WriteFile(ymlPath, bs, 0o600)
}
-72
View File
@@ -1,72 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"path/filepath"
"sync"
"time"
"github.com/adrg/xdg"
"github.com/go-signet/sdk-go/credstore"
"golang.org/x/oauth2"
)
var (
tokenStore *credstore.SecureStore[credstore.Token]
tokenStoreOnce sync.Once
saveOAuthTokenToStore = func(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
return getTokenStore().Save(loginName, credstore.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
ClientID: loginName,
})
}
deleteOAuthTokenFromStore = func(loginName string) error {
return getTokenStore().Delete(loginName)
}
)
func getTokenStore() *credstore.SecureStore[credstore.Token] {
tokenStoreOnce.Do(func() {
filePath := filepath.Join(xdg.ConfigHome, "tea", "credentials.json")
tokenStore = credstore.DefaultTokenSecureStore("tea-cli", filePath)
})
return tokenStore
}
// LoadOAuthToken loads OAuth tokens from the secure store.
func LoadOAuthToken(loginName string) (*credstore.Token, error) {
tok, err := getTokenStore().Load(loginName)
if err != nil {
return nil, err
}
return &tok, nil
}
// SaveOAuthToken saves OAuth tokens to the secure store.
func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
return saveOAuthTokenToStore(loginName, accessToken, refreshToken, expiresAt)
}
// DeleteOAuthToken removes tokens from the secure store.
func DeleteOAuthToken(loginName string) error {
return deleteOAuthTokenFromStore(loginName)
}
// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to
// the existing login's values for empty refresh token or zero expiry.
func SaveOAuthTokenFromOAuth2(loginName string, token *oauth2.Token, login *Login) error {
refreshToken := token.RefreshToken
if refreshToken == "" {
refreshToken = login.GetRefreshToken()
}
expiry := token.Expiry
if expiry.IsZero() {
expiry = login.GetTokenExpiry()
}
return SaveOAuthToken(loginName, token.AccessToken, refreshToken, expiry)
}
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"sync"
"time"
"gitea.dev/tea/modules/filelock"
)
const (
// LockTimeout is the default timeout for acquiring the config file lock.
LockTimeout = 5 * time.Second
// mutexPollInterval is how often to retry acquiring the in-process mutex.
mutexPollInterval = 10 * time.Millisecond
)
// configMutex protects in-process concurrent access to the config.
var configMutex sync.Mutex
// acquireConfigLock acquires both the in-process mutex and a file lock.
// Returns an unlock function that must be called to release both locks.
// The timeout applies to acquiring the file lock; the mutex acquisition
// uses the same timeout via a TryLock loop.
func acquireConfigLock(lockPath string, timeout time.Duration) (unlock func() error, err error) {
// Try to acquire mutex with timeout
deadline := time.Now().Add(timeout)
for {
if configMutex.TryLock() {
break
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("timeout waiting for config mutex")
}
time.Sleep(mutexPollInterval)
}
// Mutex acquired, now try file lock
remaining := max(time.Until(deadline), 0)
locker := filelock.New(lockPath, remaining)
fileUnlock, err := locker.Acquire()
if err != nil {
configMutex.Unlock()
return nil, err
}
// Return unlock function
return func() error {
unlockErr := fileUnlock()
configMutex.Unlock()
return unlockErr
}, nil
}
// getConfigLockPath returns the path to the lock file for the config.
func getConfigLockPath() string {
return GetConfigPath() + ".lock"
}
// withConfigLock executes the given function while holding the config lock.
// It acquires the lock, reloads the config from disk, executes fn, and releases the lock.
func withConfigLock(fn func() error) (retErr error) {
lockPath := getConfigLockPath()
unlock, err := acquireConfigLock(lockPath, LockTimeout)
if err != nil {
return fmt.Errorf("failed to acquire config lock: %w", err)
}
defer func() {
if unlockErr := unlock(); unlockErr != nil && retErr == nil {
retErr = fmt.Errorf("failed to release config lock: %w", unlockErr)
}
}()
// Reload config from disk to get latest state
if err := reloadConfigFromDisk(); err != nil {
return err
}
return fn()
}
-196
View File
@@ -1,196 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestConfigLock_BasicLockUnlock(t *testing.T) {
// Create a temp directory for test
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
lockPath := filepath.Join(tmpDir, "config.yml.lock")
// Should be able to acquire lock
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
if err != nil {
t.Fatalf("failed to acquire lock: %v", err)
}
// Should be able to release lock
err = unlock()
if err != nil {
t.Fatalf("failed to release lock: %v", err)
}
}
func TestConfigLock_MutexProtection(t *testing.T) {
// Create a temp directory for test
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
lockPath := filepath.Join(tmpDir, "config.yml.lock")
// Acquire lock
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
if err != nil {
t.Fatalf("failed to acquire lock: %v", err)
}
// Try to acquire again from same process - should block/timeout due to mutex
done := make(chan bool)
go func() {
_, err := acquireConfigLock(lockPath, 100*time.Millisecond)
done <- (err != nil) // Should timeout/fail
}()
select {
case failed := <-done:
if !failed {
t.Error("second lock acquisition should have failed due to mutex")
}
case <-time.After(2 * time.Second):
t.Error("test timed out")
}
if err := unlock(); err != nil {
t.Errorf("failed to unlock: %v", err)
}
}
func useTempConfigPath(t *testing.T) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
SetConfigPathForTesting(configPath)
t.Cleanup(func() {
SetConfigPathForTesting("")
})
return configPath
}
func TestReloadConfigFromDisk(t *testing.T) {
configPath := useTempConfigPath(t)
// Save original config state
originalConfig := config
config = LocalConfig{Logins: []Login{{Name: "stale"}}}
if err := os.WriteFile(configPath, []byte("logins:\n - name: test\n"), 0o600); err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
err := reloadConfigFromDisk()
if err != nil {
t.Fatalf("reloadConfigFromDisk returned error: %v", err)
}
if len(config.Logins) != 1 || config.Logins[0].Name != "test" {
t.Fatalf("expected config to reload test login, got %+v", config.Logins)
}
// Restore original config
config = originalConfig
}
func TestWithConfigLock(t *testing.T) {
useTempConfigPath(t)
executed := false
err := withConfigLock(func() error {
executed = true
return nil
})
if err != nil {
t.Errorf("withConfigLock returned error: %v", err)
}
if !executed {
t.Error("function was not executed")
}
}
func TestWithConfigLock_PropagatesError(t *testing.T) {
useTempConfigPath(t)
expectedErr := fmt.Errorf("test error")
err := withConfigLock(func() error {
return expectedErr
})
if err != expectedErr {
t.Errorf("expected error %v, got %v", expectedErr, err)
}
}
func TestDoubleCheckedLocking_SimulatedRefresh(t *testing.T) {
useTempConfigPath(t)
// This test simulates the double-checked locking pattern
// by having multiple goroutines try to "refresh" simultaneously
var (
refreshCount int
mu sync.Mutex
)
// Simulate what RefreshOAuthToken does with double-check
simulatedRefresh := func(tokenExpiry *int64) error {
// First check (without lock)
if *tokenExpiry > time.Now().Unix() {
return nil // Token still valid
}
return withConfigLock(func() error {
// Double-check after acquiring lock
if *tokenExpiry > time.Now().Unix() {
return nil // Another goroutine refreshed it
}
// Simulate refresh
mu.Lock()
refreshCount++
mu.Unlock()
time.Sleep(50 * time.Millisecond) // Simulate API call
*tokenExpiry = time.Now().Add(1 * time.Hour).Unix()
return nil
})
}
// Start with expired token
tokenExpiry := time.Now().Add(-1 * time.Hour).Unix()
// Launch multiple goroutines trying to refresh
var wg sync.WaitGroup
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
if err := simulatedRefresh(&tokenExpiry); err != nil {
t.Errorf("refresh failed: %v", err)
}
}()
}
wg.Wait()
// Should only have refreshed once due to double-checked locking
if refreshCount != 1 {
t.Errorf("expected 1 refresh, got %d", refreshCount)
}
}
-549
View File
@@ -1,549 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/debug"
"gitea.dev/tea/modules/httputil"
"gitea.dev/tea/modules/theme"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
"golang.org/x/oauth2"
)
// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens.
// This is used by config.Login.Client() for automatic token refresh.
const TokenRefreshThreshold = 5 * time.Minute
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
// AuthMethodOAuth marks a login as using OAuth with secure credential storage.
const AuthMethodOAuth = "oauth"
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token,omitempty"`
Default bool `yaml:"default"`
SSHHost string `yaml:"ssh_host"`
// optional path to the private key
SSHKey string `yaml:"ssh_key"`
Insecure bool `yaml:"insecure"`
SSHCertPrincipal string `yaml:"ssh_certificate_principal"`
SSHAgent bool `yaml:"ssh_agent"`
SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"`
SSHPassphrase string `yaml:"-"`
VersionCheck bool `yaml:"version_check"`
// User is username from gitea
User string `yaml:"user"`
// Created is auto created unix timestamp
Created int64 `yaml:"created"`
// AuthMethod indicates the authentication method ("oauth" for OAuth with credstore)
AuthMethod string `yaml:"auth_method,omitempty"`
// RefreshToken is used to renew the access token when it expires
RefreshToken string `yaml:"refresh_token,omitempty"`
// TokenExpiry is when the token expires (unix timestamp)
TokenExpiry int64 `yaml:"token_expiry,omitempty"`
}
// IsOAuth returns true if this login uses OAuth with secure credential storage.
func (l *Login) IsOAuth() bool {
return l.AuthMethod == AuthMethodOAuth
}
// loadOAuthToken loads the OAuth token from credstore, returning nil if
// this is not an OAuth login or if the load fails (caller should fallback).
func (l *Login) loadOAuthToken() *OAuthToken {
if !l.IsOAuth() {
return nil
}
tok, err := LoadOAuthToken(l.Name)
if err != nil {
return nil
}
return &OAuthToken{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresAt: tok.ExpiresAt,
}
}
// OAuthToken holds the token fields loaded from credstore.
type OAuthToken struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// GetAccessToken returns the effective access token.
// For OAuth logins, reads from credstore. For others, returns l.Token directly.
func (l *Login) GetAccessToken() string {
if tok := l.loadOAuthToken(); tok != nil {
return tok.AccessToken
}
return l.Token
}
// GetRefreshToken returns the refresh token.
func (l *Login) GetRefreshToken() string {
if tok := l.loadOAuthToken(); tok != nil {
return tok.RefreshToken
}
return l.RefreshToken
}
// GetTokenExpiry returns the token expiry time.
func (l *Login) GetTokenExpiry() time.Time {
if tok := l.loadOAuthToken(); tok != nil {
return tok.ExpiresAt
}
return time.Unix(l.TokenExpiry, 0)
}
// GetLogins return all login available by config
func GetLogins() ([]Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
return config.Logins, nil
}
// GetDefaultLogin return the default login
func GetDefaultLogin() (*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
if len(config.Logins) == 0 {
return nil, errors.New("no available login")
}
for _, l := range config.Logins {
if l.Default {
return &l, nil
}
}
return &config.Logins[0], nil
}
// SetDefaultLogin set the default login by name (case insensitive)
func SetDefaultLogin(name string) error {
return withConfigLock(func() error {
loginExist := false
for i := range config.Logins {
config.Logins[i].Default = false
if strings.EqualFold(config.Logins[i].Name, name) {
config.Logins[i].Default = true
loginExist = true
}
}
if !loginExist {
return fmt.Errorf("login '%s' not found", name)
}
return saveConfigUnsafe()
})
}
// GetLoginByName get login by name (case insensitive)
func GetLoginByName(name string) (*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
for i := range config.Logins {
if strings.EqualFold(config.Logins[i].Name, name) {
return &config.Logins[i], nil
}
}
return nil, nil
}
// GetLoginByToken get login by token
func GetLoginByToken(token string) (*Login, error) {
if token == "" {
return nil, nil
}
if err := loadConfig(); err != nil {
return nil, err
}
for _, l := range config.Logins {
if l.Token == token {
return &l, nil
}
}
return nil, nil
}
// GetLoginByHost finds a login by its server URL
func GetLoginByHost(host string) (*Login, error) {
logins, err := GetLoginsByHost(host)
if err != nil {
return nil, err
}
if len(logins) > 0 {
return logins[0], nil
}
return nil, nil
}
// GetLoginsByHost returns all logins matching a host
func GetLoginsByHost(host string) ([]*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
var matches []*Login
for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil {
return nil, err
}
if loginURL.Host == host {
matches = append(matches, &config.Logins[i])
}
}
return matches, nil
}
// DeleteLogin delete a login by name from config
func DeleteLogin(name string) error {
return withConfigLock(func() error {
idx := -1
for i, l := range config.Logins {
if strings.EqualFold(l.Name, name) {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("can not delete login '%s', does not exist", name)
}
isOAuth := config.Logins[idx].IsOAuth()
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
// Clean up credstore tokens for OAuth logins
if isOAuth {
_ = DeleteOAuthToken(name)
}
return saveConfigUnsafe()
})
}
// AddLogin save a login to config
func AddLogin(login *Login) error {
return withConfigLock(func() error {
// Check for duplicate login names
for _, existing := range config.Logins {
if strings.EqualFold(existing.Name, login.Name) {
return fmt.Errorf("login name '%s' already exists", login.Name)
}
}
// save login to global var
config.Logins = append(config.Logins, *login)
// save login to config file
return saveConfigUnsafe()
})
}
// AddOAuthLogin saves the OAuth token and login profile as one operation.
// The profile is only written after secure token storage succeeds.
func AddOAuthLogin(login *Login, accessToken, refreshToken string, expiresAt time.Time) error {
return withConfigLock(func() error {
// Check for duplicate login names before touching credential storage.
for _, existing := range config.Logins {
if strings.EqualFold(existing.Name, login.Name) {
return fmt.Errorf("login name '%s' already exists", login.Name)
}
}
if err := SaveOAuthToken(login.Name, accessToken, refreshToken, expiresAt); err != nil {
return fmt.Errorf("failed to save token to secure store: %w", err)
}
config.Logins = append(config.Logins, *login)
if err := saveConfigUnsafe(); err != nil {
config.Logins = config.Logins[:len(config.Logins)-1]
if deleteErr := DeleteOAuthToken(login.Name); deleteErr != nil {
return errors.Join(err, fmt.Errorf("failed to clean up OAuth token after config save failure: %w", deleteErr))
}
return err
}
return nil
})
}
// SaveLoginTokens updates the token fields for an existing login.
// This is used after browser-based re-authentication to save new tokens.
func SaveLoginTokens(login *Login) error {
if login.IsOAuth() {
return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry())
}
return withConfigLock(func() error {
for i, l := range config.Logins {
if strings.EqualFold(l.Name, login.Name) {
config.Logins[i].Token = login.Token
config.Logins[i].RefreshToken = login.RefreshToken
config.Logins[i].TokenExpiry = login.TokenExpiry
return saveConfigUnsafe()
}
}
return fmt.Errorf("login %s not found", login.Name)
})
}
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
// Returns nil without doing anything if no refresh is needed.
func (l *Login) RefreshOAuthTokenIfNeeded() error {
// Load once to avoid multiple credstore reads
if tok := l.loadOAuthToken(); tok != nil {
if tok.RefreshToken == "" || tok.ExpiresAt.IsZero() {
return nil
}
if time.Now().Add(TokenRefreshThreshold).After(tok.ExpiresAt) {
return l.RefreshOAuthToken()
}
return nil
}
// Non-OAuth path: use YAML fields
if l.RefreshToken == "" || l.TokenExpiry == 0 {
return nil
}
if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) {
return l.RefreshOAuthToken()
}
return nil
}
// RefreshOAuthToken refreshes the OAuth access token using the refresh token.
// It updates the login with new token information and saves it to config.
// Uses double-checked locking to avoid unnecessary refresh calls when multiple
// processes race to refresh the same token.
func (l *Login) RefreshOAuthToken() error {
if l.GetRefreshToken() == "" {
return fmt.Errorf("no refresh token available")
}
return withConfigLock(func() error {
// Double-check: after acquiring lock, re-read config and check if
// another process already refreshed the token
for i, login := range config.Logins {
if login.Name == l.Name {
// Check if token was refreshed by another process
currentExpiry := login.GetTokenExpiry()
ourExpiry := l.GetTokenExpiry()
if currentExpiry != ourExpiry && !currentExpiry.IsZero() {
if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) {
// Token was refreshed by another process, update our copy
if !login.IsOAuth() {
l.Token = login.Token
l.RefreshToken = login.RefreshToken
l.TokenExpiry = login.TokenExpiry
}
// For OAuth logins, credstore already has the latest tokens
return nil
}
}
// Still need to refresh - proceed with OAuth call
newToken, err := doOAuthRefresh(context.Background(), l)
if err != nil {
return err
}
if l.IsOAuth() {
// Save tokens to credstore; no YAML changes needed
return SaveOAuthTokenFromOAuth2(l.Name, newToken, l)
}
// Update login with new token information (legacy path)
l.Token = newToken.AccessToken
if newToken.RefreshToken != "" {
l.RefreshToken = newToken.RefreshToken
}
if !newToken.Expiry.IsZero() {
l.TokenExpiry = newToken.Expiry.Unix()
}
config.Logins[i] = *l
return saveConfigUnsafe()
}
}
return fmt.Errorf("login %s not found", l.Name)
})
}
// doOAuthRefresh performs the actual OAuth token refresh API call.
func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
// Build current token from credstore (single load) or YAML fields
var accessToken, refreshToken string
var expiry time.Time
if tok := l.loadOAuthToken(); tok != nil {
accessToken = tok.AccessToken
refreshToken = tok.RefreshToken
expiry = tok.ExpiresAt
} else {
accessToken = l.Token
refreshToken = l.RefreshToken
expiry = time.Unix(l.TokenExpiry, 0)
}
currentToken := &oauth2.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
Expiry: expiry,
}
httpClient := &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
oauth2Config := &oauth2.Config{
ClientID: DefaultClientID,
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
},
}
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
if err != nil {
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
return newToken, nil
}
// Client returns a client to operate Gitea API. You may provide additional modifiers
// for the client like gitea.SetBasicAuth() for customization
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
// Refresh OAuth token if expired or near expiry
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
os.Exit(1)
}
return l.ClientWithoutRefresh(options...)
}
// ClientWithoutRefresh returns a client to operate the Gitea API without
// attempting an automatic OAuth token refresh. Commands that need to handle
// token refresh errors themselves (such as 'tea login status') should use this
// instead of Client, which prints to stderr and exits on refresh failure.
func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Client {
// Configure transport-level timeouts so a stalled or unresponsive server
// fails fast instead of hanging forever. These bound connection setup and
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
// large attachment uploads) are unaffected.
httpClient := &http.Client{
Transport: httputil.WrapTransport(nil),
}
if l.Insecure {
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{
Jar: cookieJar,
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
}
}
// versioncheck must be prepended in options to make sure we don't hit any version checks in the sdk
if !l.VersionCheck {
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
}
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
// transport already sets: this is the SDK's own guarantee, so the UA survives
// even if the client is ever given a transport that didn't come from WrapTransport.
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
if debug.IsDebug() {
options = append(options, gitea.SetDebugMode())
}
if l.SSHCertPrincipal != "" {
if err := l.askForSSHPassphrase(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
os.Exit(1)
}
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKeyPath(), l.SSHPassphrase))
}
if l.SSHKeyFingerprint != "" {
if err := l.askForSSHPassphrase(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
os.Exit(1)
}
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKeyPath(), l.SSHPassphrase))
}
client, err := gitea.NewClient(l.URL, options...)
if err != nil {
var versionError *gitea.ErrUnknownVersion
if !errors.As(err, &versionError) {
fmt.Fprintf(os.Stderr, "Failed to create Gitea client: %s\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
}
return client
}
func (l *Login) askForSSHPassphrase() error {
if ok, err := utils.IsKeyEncrypted(l.SSHKeyPath()); ok && err == nil && l.SSHPassphrase == "" {
return huh.NewInput().
Title("ssh-key is encrypted please enter the passphrase: ").
Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword).
Value(&l.SSHPassphrase).
WithTheme(theme.GetTheme()).
Run()
}
return nil
}
// SSHKeyPath returns the on-disk SSH key to use, or an empty string when the
// login is configured to authenticate through a running ssh-agent.
func (l *Login) SSHKeyPath() string {
if l.SSHAgent {
return ""
}
return l.SSHKey
}
// GetSSHHost returns SSH host name
func (l *Login) GetSSHHost() string {
if l.SSHHost != "" {
return l.SSHHost
}
u, err := url.Parse(l.URL)
if err != nil {
return ""
}
return u.Host
}
-143
View File
@@ -1,143 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
gitea "gitea.dev/sdk"
"golang.org/x/crypto/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoginClientWithSSHPubkeyDoesNotDeadlockOnFirstRequest(t *testing.T) {
t.Parallel()
sshKeyPath, fingerprint := writeTestSSHKey(t)
var versionRequests atomic.Int32
var issueRequests atomic.Int32
var signedVersionRequests atomic.Int32
var signedIssueRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
versionRequests.Add(1)
if r.Header.Get("Signature") != "" {
signedVersionRequests.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.26.4"}`))
case "/api/v1/repos/gitea/tea/issues":
issueRequests.Add(1)
if r.Header.Get("Signature") != "" {
signedIssueRequests.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
login := &Login{
Name: "ssh-login",
URL: server.URL,
SSHKey: sshKeyPath,
SSHKeyFingerprint: fingerprint,
VersionCheck: true,
}
type result struct {
issues []*gitea.Issue
err error
}
done := make(chan result, 1)
go func() {
issues, _, err := login.Client().Issues.ListRepoIssues(context.Background(), "gitea", "tea", gitea.ListIssueOption{})
done <- result{issues: issues, err: err}
}()
select {
case res := <-done:
require.NoError(t, res.err)
assert.Empty(t, res.issues)
case <-time.After(2 * time.Second):
t.Fatal("ListRepoIssues deadlocked while bootstrapping the server version for SSH-signed requests")
}
assert.EqualValues(t, 1, versionRequests.Load())
assert.EqualValues(t, 0, signedVersionRequests.Load())
assert.EqualValues(t, 1, issueRequests.Load())
assert.EqualValues(t, 1, signedIssueRequests.Load())
}
func TestLoginSSHKeyPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
login Login
want string
}{
{
name: "disk key",
login: Login{SSHKey: "/tmp/id_ed25519"},
want: "/tmp/id_ed25519",
},
{
name: "ssh agent ignores disk key",
login: Login{SSHKey: "/tmp/id_ed25519", SSHAgent: true},
want: "",
},
{
name: "ssh agent without disk key",
login: Login{SSHAgent: true},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, tt.login.SSHKeyPath())
})
}
}
func writeTestSSHKey(t *testing.T) (string, string) {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600))
signer, err := ssh.NewSignerFromKey(privateKey)
require.NoError(t, err)
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
}
-11
View File
@@ -1,11 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import "time"
// AcquireConfigLockForTesting exposes the internal lock helper to integration tests.
func AcquireConfigLockForTesting(lockPath string, timeout time.Duration) (func() error, error) {
return acquireConfigLock(lockPath, timeout)
}
-190
View File
@@ -1,190 +0,0 @@
// 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
@@ -1,51 +0,0 @@
// 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
@@ -1,52 +0,0 @@
// 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
@@ -1,58 +0,0 @@
// 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
@@ -1,83 +0,0 @@
// 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
@@ -1,41 +0,0 @@
// 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
@@ -1,114 +0,0 @@
// 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
@@ -1,67 +0,0 @@
// 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)
}
})
}
}
-44
View File
@@ -1,44 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package debug
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
)
var debug bool
// IsDebug returns true if debug mode is enabled
func IsDebug() bool {
return debug
}
// SetDebug sets the debug mode
func SetDebug(on bool) {
debug = on
}
// Printf prints debug information if debug mode is enabled
func Printf(info string, args ...any) {
if debug {
fmt.Printf("DEBUG: "+info+"\n", args...)
}
}
// CliFlag returns the CLI flag for debug mode
func CliFlag() cli.Flag {
return &cli.BoolFlag{
Name: "debug",
Aliases: []string{"vvv"},
Usage: "Enable debug mode",
Value: false,
Action: func(ctx context.Context, cmd *cli.Command, v bool) error {
SetDebug(v)
return nil
},
}
}
-70
View File
@@ -1,70 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package filelock
import (
"fmt"
"os"
"time"
)
const (
// DefaultTimeout is the default timeout for acquiring a file lock.
DefaultTimeout = 5 * time.Second
// FileLockPollInterval is how often to retry acquiring the file lock.
FileLockPollInterval = 50 * time.Millisecond
)
// Locker provides file-based locking with timeout.
type Locker struct {
path string
timeout time.Duration
}
// New creates a Locker for the given lock file path.
func New(lockPath string, timeout time.Duration) *Locker {
return &Locker{
path: lockPath,
timeout: timeout,
}
}
// WithLock executes fn while holding the lock.
func (l *Locker) WithLock(fn func() error) (retErr error) {
unlock, err := l.Acquire()
if err != nil {
return err
}
defer func() {
if unlockErr := unlock(); unlockErr != nil && retErr == nil {
retErr = fmt.Errorf("failed to release file lock: %w", unlockErr)
}
}()
return fn()
}
// Acquire acquires the file lock and returns an unlock function.
// The caller must call the unlock function to release the lock.
func (l *Locker) Acquire() (unlock func() error, err error) {
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("failed to open lock file: %w", err)
}
if err := lockFile(file, l.timeout); err != nil {
file.Close()
return nil, fmt.Errorf("failed to acquire file lock: %w", err)
}
return func() error {
unlockErr := unlockFile(file)
closeErr := file.Close()
if unlockErr != nil {
return unlockErr
}
return closeErr
}, nil
}
-92
View File
@@ -1,92 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package filelock
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestLocker_WithLock(t *testing.T) {
tmpDir := t.TempDir()
lockPath := filepath.Join(tmpDir, "test.lock")
locker := New(lockPath, DefaultTimeout)
counter := 0
err := locker.WithLock(func() error {
counter++
return nil
})
if err != nil {
t.Fatalf("WithLock failed: %v", err)
}
if counter != 1 {
t.Errorf("Expected counter to be 1, got %d", counter)
}
// Lock file should have been created
if _, err := os.Stat(lockPath); os.IsNotExist(err) {
t.Error("Lock file should have been created")
}
}
func TestLocker_Acquire(t *testing.T) {
tmpDir := t.TempDir()
lockPath := filepath.Join(tmpDir, "test.lock")
locker := New(lockPath, DefaultTimeout)
unlock, err := locker.Acquire()
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
// Lock should be held
if unlock == nil {
t.Fatal("unlock function should not be nil")
}
// Release the lock
if err := unlock(); err != nil {
t.Fatalf("unlock failed: %v", err)
}
}
func TestLocker_ConcurrentAccess(t *testing.T) {
tmpDir := t.TempDir()
lockPath := filepath.Join(tmpDir, "test.lock")
locker := New(lockPath, 5*time.Second)
var wg sync.WaitGroup
counter := 0
numGoroutines := 10
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := locker.WithLock(func() error {
// Read-modify-write to check for race conditions
tmp := counter
time.Sleep(1 * time.Millisecond)
counter = tmp + 1
return nil
})
if err != nil {
t.Errorf("WithLock failed: %v", err)
}
}()
}
wg.Wait()
if counter != numGoroutines {
t.Errorf("Expected counter to be %d, got %d (possible race condition)", numGoroutines, counter)
}
}
-39
View File
@@ -1,39 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build unix
package filelock
import (
"fmt"
"os"
"syscall"
"time"
)
// lockFile acquires an exclusive lock on the file using flock.
// It polls with non-blocking flock until timeout.
func lockFile(file *os.File, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
return nil
}
if err != syscall.EWOULDBLOCK {
return fmt.Errorf("flock failed: %w", err)
}
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for file lock")
}
time.Sleep(FileLockPollInterval)
}
}
// unlockFile releases the lock on the file.
func unlockFile(file *os.File) error {
return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
}
-48
View File
@@ -1,48 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build windows
package filelock
import (
"fmt"
"os"
"time"
"golang.org/x/sys/windows"
)
// lockFile acquires an exclusive lock on the file using LockFileEx.
// It polls with non-blocking LockFileEx until timeout.
func lockFile(file *os.File, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
handle := windows.Handle(file.Fd())
// LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY
const flags = windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY
for {
// Lock the first byte (advisory lock)
var overlapped windows.Overlapped
err := windows.LockFileEx(handle, flags, 0, 1, 0, &overlapped)
if err == nil {
return nil
}
if err != windows.ERROR_LOCK_VIOLATION {
return fmt.Errorf("LockFileEx failed: %w", err)
}
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for file lock")
}
time.Sleep(FileLockPollInterval)
}
}
// unlockFile releases the lock on the file.
func unlockFile(file *os.File) error {
handle := windows.Handle(file.Fd())
var overlapped windows.Overlapped
return windows.UnlockFileEx(handle, 0, 1, 0, &overlapped)
}
-70
View File
@@ -1,70 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"net/url"
"os"
"strings"
"gitea.dev/tea/modules/utils"
"golang.org/x/crypto/ssh"
)
type pwCallback = func(string) (string, error)
// GetAuthForURL returns backend-agnostic auth settings for git network operations.
func GetAuthForURL(remoteURL *url.URL, authToken, keyFile string, passwordCallback pwCallback) (*AuthMethod, error) {
switch remoteURL.Scheme {
case "http", "https":
return &AuthMethod{Scheme: remoteURL.Scheme, Username: authToken}, nil
case "ssh":
if keyFile == "" {
return &AuthMethod{Scheme: remoteURL.Scheme, Username: remoteURL.User.Username()}, nil
}
expandedKeyFile, err := utils.AbsPathWithExpansion(keyFile)
if err != nil {
return nil, err
}
sshKey, err := os.ReadFile(expandedKeyFile)
if err != nil {
return nil, fmt.Errorf("can not read ssh key '%s'", expandedKeyFile)
}
auth := &AuthMethod{
Scheme: remoteURL.Scheme,
Username: remoteURL.User.Username(),
KeyFile: expandedKeyFile,
}
if _, err := ssh.ParsePrivateKey(sshKey); err == nil {
return auth, nil
}
if _, ok := err.(*ssh.PassphraseMissingError); ok {
if passwordCallback == nil {
return nil, err
}
for i := 0; i < 3; i++ {
pass, err := passwordCallback(expandedKeyFile)
if err != nil {
return nil, err
}
if _, err := ssh.ParsePrivateKeyWithPassphrase(sshKey, []byte(pass)); err == nil {
auth.KeyPassphrase = pass
return auth, nil
}
}
return nil, err
}
return nil, err
default:
return nil, fmt.Errorf("don't know how to handle url scheme %v", remoteURL.Scheme)
}
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
-103
View File
@@ -1,103 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"sort"
"sync"
)
var backendRegistry = struct {
sync.RWMutex
current string
backends map[string]Backend
}{
backends: make(map[string]Backend),
}
func init() {
mustRegisterBackend(cliBackend{})
mustUseBackend("cli")
}
// RegisterBackend makes a git backend available for later switching.
func RegisterBackend(backend Backend) error {
if backend == nil {
return fmt.Errorf("git backend is nil")
}
name := backend.Name()
if name == "" {
return fmt.Errorf("git backend name is empty")
}
backendRegistry.Lock()
defer backendRegistry.Unlock()
backendRegistry.backends[name] = backend
if backendRegistry.current == "" {
backendRegistry.current = name
}
return nil
}
func mustRegisterBackend(backend Backend) {
if err := RegisterBackend(backend); err != nil {
panic(err)
}
}
// UseBackend switches the active git backend implementation.
func UseBackend(name string) error {
backendRegistry.Lock()
defer backendRegistry.Unlock()
if _, ok := backendRegistry.backends[name]; !ok {
return fmt.Errorf("git backend %q is not registered", name)
}
backendRegistry.current = name
return nil
}
func mustUseBackend(name string) {
if err := UseBackend(name); err != nil {
panic(err)
}
}
// CurrentBackendName returns the active backend name.
func CurrentBackendName() string {
backendRegistry.RLock()
defer backendRegistry.RUnlock()
return backendRegistry.current
}
// RegisteredBackends returns all registered backend names.
func RegisteredBackends() []string {
backendRegistry.RLock()
defer backendRegistry.RUnlock()
out := make([]string, 0, len(backendRegistry.backends))
for name := range backendRegistry.backends {
out = append(out, name)
}
sort.Strings(out)
return out
}
func currentBackend() Backend {
backendRegistry.RLock()
defer backendRegistry.RUnlock()
return backendRegistry.backends[backendRegistry.current]
}
func setBackendForTesting(t testingT, backend Backend) {
t.Helper()
prev := CurrentBackendName()
mustRegisterBackend(backend)
mustUseBackend(backend.Name())
t.Cleanup(func() { mustUseBackend(prev) })
}
type testingT interface {
Cleanup(func())
Helper()
}
-76
View File
@@ -1,76 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
type fakeBackend struct{}
type fakeRepoBackend struct {
workTree string
}
func (fakeBackend) Name() string { return "fake" }
func (fakeBackend) Open(path string) (RepositoryBackend, error) {
return &fakeRepoBackend{workTree: "open:" + path}, nil
}
func (fakeBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
return &fakeRepoBackend{workTree: fmt.Sprintf("clone:%s:%s", path, remoteURL)}, nil
}
func (r *fakeRepoBackend) WorkTree() string { return r.workTree }
func (r *fakeRepoBackend) Config() (*Config, error) {
return &Config{Remotes: map[string]*RemoteConfig{}, Branches: map[string]*Branch{}}, nil
}
func (r *fakeRepoBackend) Head() (*Reference, error) {
return &Reference{name: NewBranchReferenceName("main"), hash: Hash("deadbeef")}, nil
}
func (r *fakeRepoBackend) AddRemote(name, remoteURL string) error { return nil }
func (r *fakeRepoBackend) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
return nil
}
func (r *fakeRepoBackend) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
return nil
}
func (r *fakeRepoBackend) CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error {
return nil
}
func (r *fakeRepoBackend) Checkout(ref ReferenceName) error { return nil }
func (r *fakeRepoBackend) DeleteLocalBranch(branchName string) error { return nil }
func (r *fakeRepoBackend) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
return nil
}
func (r *fakeRepoBackend) ListReferences(prefixes ...string) ([]*Reference, error) { return nil, nil }
func (r *fakeRepoBackend) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
return nil
}
func TestCanSwitchBackends(t *testing.T) {
setBackendForTesting(t, fakeBackend{})
repo, err := RepoFromPath("demo")
require.NoError(t, err)
require.Equal(t, "open:demo", repo.WorkTree())
require.Equal(t, "fake", CurrentBackendName())
cloned, err := Clone("target", "https://example.com/repo.git", nil, 1, false)
require.NoError(t, err)
require.Equal(t, "clone:target:https://example.com/repo.git", cloned.WorkTree())
}
func TestRegisteredBackendsContainsCLI(t *testing.T) {
require.Contains(t, RegisteredBackends(), "cli")
}
-226
View File
@@ -1,226 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"encoding/base64"
"fmt"
"strings"
"unicode"
)
// TeaCreateBranch creates a new branch in the repo, tracking from another branch.
func (r TeaRepo) TeaCreateBranch(localBranchName, remoteBranchName, remoteName string) error {
return r.backend.CreateTrackingBranch(localBranchName, remoteBranchName, remoteName)
}
// TeaCheckout checks out the given branch in the worktree.
func (r TeaRepo) TeaCheckout(ref ReferenceName) error {
return r.backend.Checkout(ref)
}
// TeaDeleteLocalBranch removes the given branch locally.
func (r TeaRepo) TeaDeleteLocalBranch(branch *Branch) error {
return r.backend.DeleteLocalBranch(branch.Name)
}
// TeaDeleteRemoteBranch removes the given branch on the given remote via git protocol.
func (r TeaRepo) TeaDeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
return r.backend.DeleteRemoteBranch(remoteName, remoteBranch, auth)
}
// TeaFindBranchBySha returns a branch that is at the the given SHA and syncs to the
// given remote repo.
func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *Branch, err error) {
remote, err := r.GetRemote(repoURL)
if err != nil {
return nil, err
}
if remote == nil {
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
}
remoteName := remote.Config().Name
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
if err != nil {
return nil, err
}
var remoteRefName ReferenceName
var localRefName ReferenceName
for _, ref := range refs {
if ref.Name().IsRemote() && ref.Hash().String() == sha {
remoteRefName = ref.Name()
}
if ref.Name().IsBranch() && ref.Hash().String() == sha {
localRefName = ref.Name()
}
}
if remoteRefName == "" || localRefName == "" {
return nil, nil
}
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
return b, b.Validate()
}
// TeaFindBranchByName returns a branch that is at the the given local and
// remote names and syncs to the given remote repo. This method is less precise
// than TeaFindBranchBySha(), but may be desirable if local and remote branch
// have diverged.
func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *Branch, err error) {
remote, err := r.GetRemote(repoURL)
if err != nil {
return nil, err
}
if remote == nil {
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
}
remoteName := remote.Config().Name
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
if err != nil {
return nil, err
}
var remoteRefName ReferenceName
var localRefName ReferenceName
remoteSearchingName := fmt.Sprintf("%s/%s", remoteName, branchName)
for _, ref := range refs {
if ref.Name().IsRemote() && ref.Name().Short() == remoteSearchingName {
remoteRefName = ref.Name()
}
if ref.Name().IsBranch() && ref.Name().Short() == branchName {
localRefName = ref.Name()
}
}
if remoteRefName == "" || localRefName == "" {
return nil, nil
}
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
return b, b.Validate()
}
// TeaFindBranchRemote gives the first remote that has a branch with the same name or sha,
// depending on what is passed in.
// This function is needed, as git does not always define branches in .git/config with remote entries.
// Priority order is: first match of sha and branch -> first match of branch -> first match of sha.
func (r TeaRepo) TeaFindBranchRemote(branchName, hash string) (*Remote, error) {
remotes, err := r.Remotes()
if err != nil {
return nil, err
}
switch {
case len(remotes) == 0:
return nil, nil
case len(remotes) == 1:
return remotes[0], nil
}
refs, err := r.backend.ListReferences("refs/remotes")
if err != nil {
return nil, err
}
remoteByName := make(map[string]*Remote, len(remotes))
for _, remote := range remotes {
remoteByName[remote.Config().Name] = remote
}
var shaMatch *Remote
var branchMatch *Remote
var fullMatch *Remote
for _, ref := range refs {
remoteName, remoteBranch, ok := splitRemoteRef(ref.Name())
if !ok {
continue
}
remote := remoteByName[remoteName]
if remote == nil {
continue
}
if branchMatch == nil && branchName != "" && branchName == remoteBranch {
branchMatch = remote
}
if shaMatch == nil && hash != "" && hash == ref.Hash().String() {
shaMatch = remote
}
if fullMatch == nil && branchName != "" && branchName == remoteBranch && hash != "" && hash == ref.Hash().String() {
fullMatch = remote
break
}
}
switch {
case fullMatch != nil:
return fullMatch, nil
case branchMatch != nil:
return branchMatch, nil
case shaMatch != nil:
return shaMatch, nil
default:
return nil, nil
}
}
// TeaGetCurrentBranchNameAndSHA return the name and sha of the branch witch is currently active.
func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) {
localHead, err := r.Head()
if err != nil {
return "", "", err
}
if !localHead.Name().IsBranch() {
return "", "", fmt.Errorf("active ref is no branch")
}
return localHead.Name().Short(), localHead.Hash().String(), nil
}
// PushToCreatAgitFlowPR pushes the given head to the refs/for/<base>/<topic> ref on the remote to create an agit flow PR.
func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, description string, auth *AuthMethod) error {
if !strings.HasPrefix(head, "refs/") {
head = "refs/heads/" + head
}
pushOptions := make(map[string]string)
if len(title) > 0 {
pushOptions["title"] = b64Encode(title)
}
if len(description) > 0 {
pushOptions["description"] = b64Encode(description)
}
return r.backend.PushToAgitFlowPR(remoteName, head, base, topic, pushOptions, auth)
}
func splitRemoteRef(ref ReferenceName) (remoteName, branchName string, ok bool) {
if !ref.IsRemote() {
return "", "", false
}
parts := strings.SplitN(ref.Short(), "/", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// b64Encode implements base64 encode for string if necessary.
func b64Encode(s string) string {
if strings.Contains(s, "\n") || !isASCII(s) {
return "{base64}" + base64.StdEncoding.EncodeToString([]byte(s))
}
return s
}
// isASCII indicates string contains only ASCII.
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > unicode.MaxASCII {
return false
}
}
return true
}
-402
View File
@@ -1,402 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
)
type cliBackend struct{}
type cliRepository struct {
workTree string
}
func (cliBackend) Name() string {
return "cli"
}
func (b cliBackend) Open(path string) (RepositoryBackend, error) {
if path == "" {
path = "."
}
out, err := runGitCommand(path, nil, nil, "rev-parse", "--show-toplevel")
if err != nil {
return nil, classifyRepoError(err)
}
return &cliRepository{workTree: strings.TrimSpace(out)}, nil
}
func (b cliBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
extraConfigs := make([]string, 0, 1)
if opts.Insecure {
extraConfigs = append(extraConfigs, "http.sslVerify=false")
}
args := []string{"clone"}
if opts.Depth > 0 {
args = append(args, "--depth", strconv.Itoa(opts.Depth))
}
args = append(args, remoteURL, path)
if _, err := runGitCommand("", auth, extraConfigs, args...); err != nil {
return nil, err
}
return b.Open(path)
}
func (r *cliRepository) WorkTree() string {
return r.workTree
}
func (r *cliRepository) Config() (*Config, error) {
cfg := &Config{
Remotes: map[string]*RemoteConfig{},
Branches: map[string]*Branch{},
}
remoteOut, err := r.git(nil, nil, "remote")
if err != nil {
return nil, err
}
for _, remoteName := range strings.Fields(remoteOut) {
urlOut, err := r.git(nil, nil, "config", "--get-all", "remote."+remoteName+".url")
if err != nil {
return nil, err
}
cfg.Remotes[remoteName] = &RemoteConfig{Name: remoteName, URLs: splitNonEmptyLines(urlOut)}
}
branchOut, err := r.git(nil, nil, "config", "--get-regexp", `^branch\..*\.(remote|merge)$`)
if err != nil {
var gitErr *gitCommandError
if !errors.As(err, &gitErr) {
return nil, err
}
if strings.TrimSpace(gitErr.stderr) != "" && !strings.Contains(gitErr.stderr, "No such section or key") {
return nil, err
}
return cfg, nil
}
for _, line := range splitNonEmptyLines(branchOut) {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
branchName, field, ok := parseBranchConfigKey(parts[0])
if !ok {
continue
}
branch := cfg.Branches[branchName]
if branch == nil {
branch = &Branch{Name: branchName}
cfg.Branches[branchName] = branch
}
switch field {
case "remote":
branch.Remote = parts[1]
case "merge":
branch.Merge = ReferenceName(parts[1])
}
}
return cfg, nil
}
func (r *cliRepository) Head() (*Reference, error) {
hashOut, err := r.git(nil, nil, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
hash := Hash(strings.TrimSpace(hashOut))
if refOut, err := r.git(nil, nil, "symbolic-ref", "-q", "HEAD"); err == nil {
return &Reference{name: ReferenceName(strings.TrimSpace(refOut)), hash: hash}, nil
}
if tagOut, err := r.git(nil, nil, "describe", "--exact-match", "--tags", "HEAD"); err == nil {
return &Reference{name: ReferenceName("refs/tags/" + strings.TrimSpace(tagOut)), hash: hash}, nil
}
return &Reference{name: ReferenceName("HEAD"), hash: hash}, nil
}
func (r *cliRepository) AddRemote(name, remoteURL string) error {
_, err := r.git(nil, nil, "remote", "add", name, remoteURL)
return err
}
func (r *cliRepository) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
mergeRef := NewBranchReferenceName(remoteBranch).String()
if _, err := r.git(nil, nil, "config", "branch."+branchName+".remote", remoteName); err != nil {
return err
}
_, err := r.git(nil, nil, "config", "branch."+branchName+".merge", mergeRef)
return err
}
func (r *cliRepository) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
args := []string{"fetch", remoteName}
args = append(args, refspecs...)
_, err := r.git(auth, nil, args...)
return err
}
func (r *cliRepository) CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error {
_, err := r.git(nil, nil, "branch", "--track", localBranchName, fmt.Sprintf("%s/%s", remoteName, remoteBranchName))
if err == nil {
return nil
}
if gitErr, ok := err.(*gitCommandError); ok && strings.Contains(gitErr.stderr, "already exists") {
return ErrBranchExists
}
return err
}
func (r *cliRepository) Checkout(ref ReferenceName) error {
args := []string{"checkout"}
switch {
case ref.IsBranch():
// `git checkout refs/heads/<branch>` detaches HEAD, while the short branch
// name switches to the local branch as intended.
args = append(args, ref.Short())
case ref.IsRemote():
// Be explicit about detached HEAD when checking out a remote-tracking ref.
args = append(args, "--detach", ref.String())
default:
args = append(args, ref.String())
}
_, err := r.git(nil, nil, args...)
return err
}
func (r *cliRepository) DeleteLocalBranch(branchName string) error {
_, err := r.git(nil, nil, "branch", "-D", branchName)
if err == nil {
return nil
}
if gitErr, ok := err.(*gitCommandError); ok {
stderr := strings.ToLower(gitErr.stderr)
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "not exist") {
return nil
}
}
return err
}
func (r *cliRepository) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
_, err := r.git(auth, nil, "push", "--delete", remoteName, remoteBranch)
return err
}
func (r *cliRepository) ListReferences(prefixes ...string) ([]*Reference, error) {
args := []string{"for-each-ref", "--format=%(objectname)%09%(refname)"}
args = append(args, prefixes...)
out, err := r.git(nil, nil, args...)
if err != nil {
return nil, err
}
refs := make([]*Reference, 0)
for _, line := range splitNonEmptyLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
refs = append(refs, &Reference{name: ReferenceName(parts[1]), hash: Hash(parts[0])})
}
return refs, nil
}
func (r *cliRepository) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic)
args := []string{"push", remoteName, ref}
if len(pushOptions) > 0 {
keys := make([]string, 0, len(pushOptions))
for key := range pushOptions {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
args = append(args, "-o", key+"="+pushOptions[key])
}
}
_, err := r.git(auth, nil, args...)
return err
}
func (r *cliRepository) git(auth *AuthMethod, extraConfigs []string, args ...string) (string, error) {
return runGitCommand(r.workTree, auth, extraConfigs, args...)
}
type gitCommandError struct {
args []string
stderr string
err error
}
func (e *gitCommandError) Error() string {
stderr := strings.TrimSpace(e.stderr)
if stderr == "" {
return fmt.Sprintf("git %s: %v", strings.Join(e.args, " "), e.err)
}
return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), stderr)
}
func (e *gitCommandError) Unwrap() error {
return e.err
}
func gitCommandEnv(authEnv []string) []string {
baseEnv := os.Environ()
env := make([]string, 0, len(baseEnv)+len(authEnv)+3)
for _, value := range baseEnv {
if strings.HasPrefix(value, "LC_ALL=") ||
strings.HasPrefix(value, "LANG=") ||
strings.HasPrefix(value, "LANGUAGE=") {
continue
}
env = append(env, value)
}
env = append(env, authEnv...)
return append(env, "LC_ALL=C", "LANG=C", "LANGUAGE=C")
}
func runGitCommand(dir string, auth *AuthMethod, extraConfigs []string, args ...string) (string, error) {
authConfigs, authEnv, cleanup, err := prepareCLIAuth(auth)
if err != nil {
return "", err
}
defer cleanup()
fullArgs := make([]string, 0, (len(extraConfigs)+len(authConfigs))*2+len(args))
for _, cfg := range extraConfigs {
fullArgs = append(fullArgs, "-c", cfg)
}
for _, cfg := range authConfigs {
fullArgs = append(fullArgs, "-c", cfg)
}
fullArgs = append(fullArgs, args...)
cmd := exec.Command("git", fullArgs...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = gitCommandEnv(authEnv)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", &gitCommandError{args: fullArgs, stderr: stderr.String(), err: err}
}
return stdout.String(), nil
}
func prepareCLIAuth(auth *AuthMethod) ([]string, []string, func(), error) {
if auth == nil {
return nil, nil, func() {}, nil
}
configs := make([]string, 0, 1)
env := make([]string, 0, 4)
cleanup := func() {}
switch auth.Scheme {
case "http", "https":
if auth.Username != "" || auth.Password != "" {
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(auth.Username+":"+auth.Password))
configs = append(configs, "http.extraHeader="+header)
}
case "ssh":
sshCommand := "ssh"
if auth.KeyFile != "" {
sshCommand += " -i " + shellQuote(auth.KeyFile) + " -o IdentitiesOnly=yes"
}
env = append(env, "GIT_SSH_COMMAND="+sshCommand)
if auth.KeyPassphrase != "" {
askPassPath, err := writeAskPassScript(auth.KeyPassphrase)
if err != nil {
return nil, nil, cleanup, err
}
cleanup = func() { _ = os.Remove(askPassPath) }
env = append(env,
"SSH_ASKPASS="+askPassPath,
"SSH_ASKPASS_REQUIRE=force",
"DISPLAY=tea",
)
}
}
return configs, env, cleanup, nil
}
func writeAskPassScript(passphrase string) (string, error) {
f, err := os.CreateTemp("", "tea-ssh-askpass-*")
if err != nil {
return "", err
}
defer f.Close()
content := "#!/bin/sh\nprintf '%s\\n' " + shellQuote(passphrase) + "\n"
if _, err := f.WriteString(content); err != nil {
_ = os.Remove(f.Name())
return "", err
}
if err := f.Chmod(0o700); err != nil {
_ = os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
func classifyRepoError(err error) error {
var gitErr *gitCommandError
if errors.As(err, &gitErr) {
msg := strings.ToLower(gitErr.stderr)
if strings.Contains(msg, "not a git repository") || strings.Contains(msg, "cannot change to") {
return ErrRepositoryNotExists
}
}
return err
}
func parseBranchConfigKey(key string) (branchName, field string, ok bool) {
const prefix = "branch."
if !strings.HasPrefix(key, prefix) {
return "", "", false
}
trimmed := strings.TrimPrefix(key, prefix)
switch {
case strings.HasSuffix(trimmed, ".remote"):
return strings.TrimSuffix(trimmed, ".remote"), "remote", true
case strings.HasSuffix(trimmed, ".merge"):
return strings.TrimSuffix(trimmed, ".merge"), "merge", true
default:
return "", "", false
}
}
func splitNonEmptyLines(s string) []string {
lines := strings.Split(strings.TrimSpace(s), "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
-40
View File
@@ -1,40 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
func TestOpenClassifiesLocalizedNotRepoError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell script as fake git")
}
fakeGitDir := t.TempDir()
fakeGit := filepath.Join(fakeGitDir, "git")
script := `#!/bin/sh
locale="${LC_ALL:-${LANG:-C}}"
if [ "$locale" = "C" ]; then
echo "fatal: not a git repository (or any of the parent directories): .git" >&2
else
echo "fatal: Kein Git-Repository (oder irgendein Elternverzeichnis bis zum Einhangepunkt /)" >&2
fi
exit 128
`
require.NoError(t, os.WriteFile(fakeGit, []byte(script), 0o755))
t.Setenv("PATH", fakeGitDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("LC_ALL", "de_DE.UTF-8")
t.Setenv("LANG", "de_DE.UTF-8")
t.Setenv("LANGUAGE", "de_DE:de")
_, err := (cliBackend{}).Open(t.TempDir())
require.ErrorIs(t, err, ErrRepositoryNotExists)
}
-28
View File
@@ -1,28 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
// Clone clones a repository using the active backend.
func Clone(path, remoteURL string, auth *AuthMethod, depth int, insecure bool) (*TeaRepo, error) {
backend, err := currentBackend().Clone(path, remoteURL, auth, CloneOptions{Depth: depth, Insecure: insecure})
if err != nil {
return nil, err
}
return newTeaRepo(backend), nil
}
// AddRemote adds a new remote to the repository.
func (r TeaRepo) AddRemote(name, remoteURL string) error {
return r.backend.AddRemote(name, remoteURL)
}
// SetBranchUpstream configures the branch's upstream remote.
func (r TeaRepo) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
return r.backend.SetBranchUpstream(branchName, remoteName, remoteBranch)
}
// Fetch fetches updates from the named remote.
func (r TeaRepo) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
return r.backend.Fetch(remoteName, refspecs, auth)
}
-68
View File
@@ -1,68 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"net/url"
)
// GetRemote tries to match a Remote of the repo via the given URL.
// Matching is based on the normalized URL, accepting different protocols.
func (r TeaRepo) GetRemote(remoteURL string) (*Remote, error) {
repoURL, err := ParseURL(remoteURL)
if err != nil {
return nil, err
}
remotes, err := r.Remotes()
if err != nil {
return nil, err
}
for _, remote := range remotes {
for _, u := range remote.Config().URLs {
parsedRemoteURL, err := ParseURL(u)
if err != nil {
return nil, err
}
if parsedRemoteURL.Host == repoURL.Host && parsedRemoteURL.Path == repoURL.Path {
return remote, nil
}
}
}
return nil, nil
}
// GetOrCreateRemote tries to match a Remote of the repo via the given URL.
// If no match is found, a new Remote with `newRemoteName` is created.
// Matching is based on the normalized URL, accepting different protocols.
func (r TeaRepo) GetOrCreateRemote(remoteURL, newRemoteName string) (*Remote, error) {
localRemote, err := r.GetRemote(remoteURL)
if err != nil {
return nil, err
}
if localRemote == nil {
if err := r.AddRemote(newRemoteName, remoteURL); err != nil {
return nil, err
}
return r.Remote(newRemoteName)
}
return localRemote, nil
}
// TeaRemoteURL returns the first url entry for the given remote name.
func (r TeaRepo) TeaRemoteURL(name string) (auth *url.URL, err error) {
remote, err := r.Remote(name)
if err != nil {
return nil, err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return nil, fmt.Errorf("remote %s has no URL configured", name)
}
return ParseURL(urls[0])
}
-94
View File
@@ -1,94 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"net/url"
"sort"
)
// TeaRepo wraps a local git repository behind a swappable backend.
type TeaRepo struct {
backend RepositoryBackend
}
func newTeaRepo(backend RepositoryBackend) *TeaRepo {
return &TeaRepo{backend: backend}
}
// RepoForWorkdir tries to open the git repository in the local directory
// for reading or modification.
func RepoForWorkdir() (*TeaRepo, error) {
return RepoFromPath("")
}
// RepoFromPath tries to open the git repository by path.
func RepoFromPath(path string) (*TeaRepo, error) {
backend, err := currentBackend().Open(path)
if err != nil {
return nil, err
}
return newTeaRepo(backend), nil
}
// WorkTree returns the repository work tree path.
func (r TeaRepo) WorkTree() string {
return r.backend.WorkTree()
}
// Config returns the repository config values tea needs.
func (r TeaRepo) Config() (*Config, error) {
return r.backend.Config()
}
// Remote returns the configured remote by name.
func (r TeaRepo) Remote(remoteName string) (*Remote, error) {
cfg, err := r.Config()
if err != nil {
return nil, err
}
remoteCfg, ok := cfg.Remotes[remoteName]
if !ok {
return nil, fmt.Errorf("remote %s not found", remoteName)
}
return &Remote{repo: &r, config: remoteCfg}, nil
}
// Remotes returns all configured remotes sorted by name.
func (r TeaRepo) Remotes() ([]*Remote, error) {
cfg, err := r.Config()
if err != nil {
return nil, err
}
remoteNames := make([]string, 0, len(cfg.Remotes))
for name := range cfg.Remotes {
remoteNames = append(remoteNames, name)
}
sort.Strings(remoteNames)
remotes := make([]*Remote, 0, len(remoteNames))
for _, name := range remoteNames {
remotes = append(remotes, &Remote{repo: &r, config: cfg.Remotes[name]})
}
return remotes, nil
}
// Head returns the currently checked out ref.
func (r TeaRepo) Head() (*Reference, error) {
return r.backend.Head()
}
// RemoteURL returns the URL of the given remote.
func (r TeaRepo) RemoteURL(remoteName string) (*url.URL, error) {
remote, err := r.Remote(remoteName)
if err != nil {
return nil, err
}
if len(remote.Config().URLs) == 0 {
return nil, fmt.Errorf("remote %s has no URL configured", remoteName)
}
return ParseURL(remote.Config().URLs[0])
}
-136
View File
@@ -1,136 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "git %v failed: %s", args, out)
return string(out)
}
func tryGit(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
_, err := cmd.CombinedOutput()
return err
}
func TestRepoFromPathSupportsWorktrees(t *testing.T) {
tmpDir := t.TempDir()
mainRepoPath := filepath.Join(tmpDir, "main-repo")
worktreePath := filepath.Join(tmpDir, "worktree")
runGit(t, "", "init", mainRepoPath)
runGit(t, mainRepoPath, "config", "user.email", "test@example.com")
runGit(t, mainRepoPath, "config", "user.name", "Test User")
runGit(t, mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
readmePath := filepath.Join(mainRepoPath, "README.md")
require.NoError(t, os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644))
runGit(t, mainRepoPath, "add", "README.md")
runGit(t, mainRepoPath, "commit", "-m", "Initial commit")
runGit(t, mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
repo, err := RepoFromPath(worktreePath)
require.NoError(t, err)
config, err := repo.Config()
require.NoError(t, err)
require.Contains(t, config.Remotes, "origin")
require.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0])
head, err := repo.Head()
require.NoError(t, err)
require.Equal(t, "test-branch", head.Name().Short())
}
func TestRepoFromPathSupportsSHA256Repos(t *testing.T) {
tmpDir := t.TempDir()
repoPath := filepath.Join(tmpDir, "sha256-repo")
if err := tryGit("", "init", "--object-format=sha256", repoPath); err != nil {
t.Skip("git does not support sha256 object format in this environment")
}
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
readmePath := filepath.Join(repoPath, "README.md")
require.NoError(t, os.WriteFile(readmePath, []byte("sha256\n"), 0o644))
runGit(t, repoPath, "add", "README.md")
runGit(t, repoPath, "commit", "-m", "Initial commit")
repo, err := RepoFromPath(repoPath)
require.NoError(t, err)
branch, sha, err := repo.TeaGetCurrentBranchNameAndSHA()
require.NoError(t, err)
require.NotEmpty(t, branch)
require.Len(t, sha, 64)
config, err := repo.Config()
require.NoError(t, err)
require.Contains(t, config.Remotes, "origin")
}
func TestTeaFindBranchByShaAndName(t *testing.T) {
tmpDir := t.TempDir()
remotePath := filepath.Join(tmpDir, "remote.git")
localPath := filepath.Join(tmpDir, "local")
runGit(t, "", "init", "--bare", remotePath)
runGit(t, "", "clone", remotePath, localPath)
runGit(t, localPath, "config", "user.email", "test@example.com")
runGit(t, localPath, "config", "user.name", "Test User")
filePath := filepath.Join(localPath, "README.md")
require.NoError(t, os.WriteFile(filePath, []byte("main\n"), 0o644))
runGit(t, localPath, "add", "README.md")
runGit(t, localPath, "commit", "-m", "Initial commit")
runGit(t, localPath, "branch", "-M", "main")
runGit(t, localPath, "push", "-u", "origin", "main")
runGit(t, localPath, "checkout", "-b", "feature/demo")
require.NoError(t, os.WriteFile(filePath, []byte("feature\n"), 0o644))
runGit(t, localPath, "commit", "-am", "Feature commit")
runGit(t, localPath, "push", "-u", "origin", "feature/demo")
repo, err := RepoFromPath(localPath)
require.NoError(t, err)
sha := strings.TrimSpace(runGit(t, localPath, "rev-parse", "HEAD"))
branchBySha, err := repo.TeaFindBranchBySha(sha, remotePath)
require.NoError(t, err)
require.NotNil(t, branchBySha)
require.Equal(t, "feature/demo", branchBySha.Name)
require.Equal(t, "origin", branchBySha.Remote)
branchByName, err := repo.TeaFindBranchByName("feature/demo", remotePath)
require.NoError(t, err)
require.NotNil(t, branchByName)
require.Equal(t, "feature/demo", branchByName.Name)
remote, err := repo.TeaFindBranchRemote("feature/demo", sha)
require.NoError(t, err)
require.NotNil(t, remote)
require.Equal(t, "origin", remote.Config().Name)
}
-163
View File
@@ -1,163 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"errors"
"strings"
)
var (
// ErrRepositoryNotExists indicates the requested path is not inside a git repository.
ErrRepositoryNotExists = errors.New("repository does not exist")
// ErrBranchExists indicates the requested branch already exists locally.
ErrBranchExists = errors.New("branch already exists")
)
// AuthMethod carries backend-agnostic authentication information for git operations.
type AuthMethod struct {
Scheme string
Username string
Password string
KeyFile string
KeyPassphrase string
}
// CloneOptions describes repository clone behavior.
type CloneOptions struct {
Depth int
Insecure bool
}
// RepositoryBackend is the backend abstraction used by TeaRepo.
type RepositoryBackend interface {
WorkTree() string
Config() (*Config, error)
Head() (*Reference, error)
AddRemote(name, remoteURL string) error
SetBranchUpstream(branchName, remoteName, remoteBranch string) error
Fetch(remoteName string, refspecs []string, auth *AuthMethod) error
CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error
Checkout(ref ReferenceName) error
DeleteLocalBranch(branchName string) error
DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error
ListReferences(prefixes ...string) ([]*Reference, error)
PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error
}
// Backend opens and clones repositories using a concrete git implementation.
type Backend interface {
Name() string
Open(path string) (RepositoryBackend, error)
Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error)
}
// Config mirrors the repository config fields tea needs.
type Config struct {
Remotes map[string]*RemoteConfig
Branches map[string]*Branch
}
// RemoteConfig stores remote configuration.
type RemoteConfig struct {
Name string
URLs []string
}
// Branch stores branch configuration.
type Branch struct {
Name string
Remote string
Merge ReferenceName
}
// Validate checks whether the branch contains the fields tea needs.
func (b *Branch) Validate() error {
if b == nil || b.Name == "" {
return errors.New("branch name is required")
}
return nil
}
// Remote wraps a configured git remote.
type Remote struct {
repo *TeaRepo
config *RemoteConfig
}
// Config returns the remote configuration.
func (r *Remote) Config() *RemoteConfig {
if r == nil {
return nil
}
return r.config
}
// ReferenceName identifies a git reference.
type ReferenceName string
func (r ReferenceName) String() string { return string(r) }
// Short returns the short display name for the reference.
func (r ReferenceName) Short() string {
s := string(r)
switch {
case strings.HasPrefix(s, "refs/heads/"):
return strings.TrimPrefix(s, "refs/heads/")
case strings.HasPrefix(s, "refs/remotes/"):
return strings.TrimPrefix(s, "refs/remotes/")
case strings.HasPrefix(s, "refs/tags/"):
return strings.TrimPrefix(s, "refs/tags/")
default:
return s
}
}
// IsBranch reports whether the reference points to a local branch.
func (r ReferenceName) IsBranch() bool {
return strings.HasPrefix(string(r), "refs/heads/")
}
// IsRemote reports whether the reference points to a remote-tracking branch.
func (r ReferenceName) IsRemote() bool {
return strings.HasPrefix(string(r), "refs/remotes/")
}
// IsTag reports whether the reference points to a tag.
func (r ReferenceName) IsTag() bool {
return strings.HasPrefix(string(r), "refs/tags/")
}
// Hash wraps a git object id.
type Hash string
func (h Hash) String() string { return string(h) }
// Reference stores a resolved git ref and its hash.
type Reference struct {
name ReferenceName
hash Hash
}
// Name returns the reference name.
func (r *Reference) Name() ReferenceName { return r.name }
// Hash returns the reference hash.
func (r *Reference) Hash() Hash { return r.hash }
// NewBranchReferenceName constructs a local branch ref name.
func NewBranchReferenceName(name string) ReferenceName {
if strings.HasPrefix(name, "refs/") {
return ReferenceName(name)
}
return ReferenceName("refs/heads/" + name)
}
// NewRemoteReferenceName constructs a remote-tracking ref name.
func NewRemoteReferenceName(remote, name string) ReferenceName {
if strings.HasPrefix(name, "refs/") {
return ReferenceName(name)
}
return ReferenceName("refs/remotes/" + remote + "/" + name)
}
-58
View File
@@ -1,58 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"net/url"
"regexp"
"strings"
)
var protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
// URLParser represents a git URL parser
type URLParser struct{}
// Parse parses the git URL
func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
rawURL = strings.TrimSpace(rawURL)
if !protocolRe.MatchString(rawURL) {
// convert the weird git ssh url format to a canonical url:
// git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea
if strings.Contains(rawURL, ":") &&
// not a Windows path
!strings.Contains(rawURL, "\\") {
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
} else if !strings.Contains(rawURL, "@") &&
strings.Count(rawURL, "/") == 2 {
// match cases like gitea.com/gitea/tea
rawURL = "https://" + rawURL
}
}
u, err = url.Parse(rawURL)
if err != nil {
return
}
if u.Scheme == "git+ssh" {
u.Scheme = "ssh"
}
if strings.HasPrefix(u.Path, "//") {
u.Path = strings.TrimPrefix(u.Path, "/")
}
// .git suffix is optional and breaks normalization
u.Path = strings.TrimSuffix(u.Path, ".git")
return
}
// ParseURL parses URL string and return URL struct
func ParseURL(rawURL string) (u *url.URL, err error) {
p := &URLParser{}
return p.Parse(rawURL)
}
-55
View File
@@ -1,55 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseUrl(t *testing.T) {
u, err := ParseURL("ssh://git@gitea.com:3000/gitea/tea")
assert.NoError(t, err)
assert.Equal(t, "gitea.com:3000", u.Host)
assert.Equal(t, "ssh", u.Scheme)
assert.Equal(t, "/gitea/tea", u.Path)
u, err = ParseURL("https://gitea.com/gitea/tea")
assert.NoError(t, err)
assert.Equal(t, "gitea.com", u.Host)
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "/gitea/tea", u.Path)
u, err = ParseURL("git@gitea.com:gitea/tea")
assert.NoError(t, err)
assert.Equal(t, "gitea.com", u.Host)
assert.Equal(t, "ssh", u.Scheme)
assert.Equal(t, "/gitea/tea", u.Path)
u, err = ParseURL("gitea.com/gitea/tea")
assert.NoError(t, err)
assert.Equal(t, "gitea.com", u.Host)
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "/gitea/tea", u.Path)
u, err = ParseURL("foo/bar")
assert.NoError(t, err)
assert.Equal(t, "", u.Host)
assert.Equal(t, "", u.Scheme)
assert.Equal(t, "foo/bar", u.Path)
u, err = ParseURL("/foo/bar")
assert.NoError(t, err)
assert.Equal(t, "", u.Host)
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "/foo/bar", u.Path)
// this case is unintuitive, but to ambiguous to be handled differently
u, err = ParseURL("gitea.com")
assert.NoError(t, err)
assert.Equal(t, "", u.Host)
assert.Equal(t, "", u.Scheme)
assert.Equal(t, "gitea.com", u.Path)
}
-46
View File
@@ -1,46 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"crypto/tls"
"fmt"
"net/http"
"runtime"
"gitea.dev/tea/modules/version"
)
// UserAgent returns the standard User-Agent string for tea.
func UserAgent() string {
ua := fmt.Sprintf("tea/%s (%s/%s)", version.Version, runtime.GOOS, runtime.GOARCH)
if version.SDK != "" {
ua += fmt.Sprintf(" go-sdk/%s", version.SDK)
}
return ua
}
// WrapTransport returns tea's standard HTTP transport: an *http.Transport
// preset with tea's connection / response-header timeouts (see timeoutTransport)
// and decorated to add the User-Agent header on every request. The supplied
// tlsConfig is attached as-is (nil is fine); callers use it for insecure /
// skip-verify logins. This is the single entry point for building a tea HTTP
// client transport, so the timeouts can't be accidentally omitted.
func WrapTransport(tlsConfig *tls.Config) http.RoundTripper {
return &userAgentTransport{base: timeoutTransport(tlsConfig)}
}
type userAgentTransport struct {
base http.RoundTripper
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Set the UA at the transport so every client built from WrapTransport
// identifies itself, including the non-SDK clients (oauth2 flow, token
// refresh) that never pass through the SDK's own SetUserAgent. For SDK
// clients this overlaps gitea.SetUserAgent; both use httputil.UserAgent(),
// so the duplicate Header.Set is a no-op.
req.Header.Set("User-Agent", UserAgent())
return t.base.RoundTrip(req)
}
-57
View File
@@ -1,57 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"crypto/tls"
"net"
"net/http"
"time"
)
// Timeout values applied to every Gitea API request. These are deliberately
// connection-establishment and time-to-first-response-byte timeouts, NOT an
// overall request deadline: a large release-attachment upload can legitimately
// run for minutes, and as long as bytes keep flowing none of these fire. They
// only trip when a server accepts the connection but never (or far too slowly)
// starts responding — the "hangs forever" case from a stalled or unresponsive
// server (issue #1018).
const (
// DialTimeout bounds establishing the TCP connection.
DialTimeout = 10 * time.Second
// TLSHandshakeTimeout bounds completing the TLS handshake.
TLSHandshakeTimeout = 10 * time.Second
// ResponseHeaderTimeout bounds the wait, after the request is written, for
// the server to begin sending response headers. This is the only timeout
// that protects against a server which accepts the connection but then goes
// silent — the originally reported #1018 symptom; DialTimeout/
// TLSHandshakeTimeout do not, because the connection already succeeded.
//
// The value must clear Gitea's legitimate synchronous pre-response work.
// Profiling a self-hosted Gitea 1.24.6 (on hardware slower than gitea.com)
// showed creating a pull request that triggers conflict detection across
// ~1500 changed files takes ~10s before the first byte (3 runs: 10.06 /
// 10.08 / 10.24s); clean-diff PR creation was ~1s and large attachment
// uploads ~9ms. 120s is ~12x that measured worst case, leaving generous
// headroom for larger repos and busier servers while still failing in two
// minutes instead of hanging forever.
ResponseHeaderTimeout = 120 * time.Second
)
// timeoutTransport returns an *http.Transport configured with tea's standard
// timeouts. The supplied tlsConfig is attached as-is (callers use it for
// insecure / skip-verify logins). It is a clone of http.DefaultTransport so
// connection pooling, proxy support and HTTP/2 keep working. Callers obtain it
// through WrapTransport, which also adds the User-Agent header.
func timeoutTransport(tlsConfig *tls.Config) *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = (&net.Dialer{
Timeout: DialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext
t.TLSHandshakeTimeout = TLSHandshakeTimeout
t.ResponseHeaderTimeout = ResponseHeaderTimeout
t.TLSClientConfig = tlsConfig
return t
}
-91
View File
@@ -1,91 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"net"
"net/http"
"testing"
"time"
)
// TestWrapTransportTimeouts verifies the transport returned by WrapTransport
// carries tea's standard timeout values, so a stalled server can't make tea
// hang forever (issue #1018).
func TestWrapTransportTimeouts(t *testing.T) {
rt := WrapTransport(nil)
uat, ok := rt.(*userAgentTransport)
if !ok {
t.Fatalf("WrapTransport returned %T, want *userAgentTransport", rt)
}
tr, ok := uat.base.(*http.Transport)
if !ok {
t.Fatalf("underlying base is %T, want *http.Transport", uat.base)
}
if tr.TLSHandshakeTimeout != TLSHandshakeTimeout {
t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, TLSHandshakeTimeout)
}
if tr.ResponseHeaderTimeout != ResponseHeaderTimeout {
t.Errorf("ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, ResponseHeaderTimeout)
}
if tr.DialContext == nil {
t.Error("DialContext is nil, want a dialer with DialTimeout")
}
}
// newStallListener returns a listener that accepts connections, reads the
// request, then goes silent without ever sending response headers — the
// "server accepts the connection but never responds" case ResponseHeaderTimeout
// guards against. The returned closer stops the listener.
func newStallListener(t *testing.T) (addr string, closer func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
buf := make([]byte, 4096)
_, _ = c.Read(buf) // drain the request, then never respond
<-done // hold the connection open until the test ends
c.Close()
}(conn)
}
}()
return ln.Addr().String(), func() {
close(done)
ln.Close()
}
}
// TestResponseHeaderTimeoutFires proves a request to a server that accepts the
// connection and request but never sends response headers aborts via
// ResponseHeaderTimeout rather than hanging. It builds the transport the same
// way WrapTransport does, with a short ResponseHeaderTimeout so the test is fast.
func TestResponseHeaderTimeoutFires(t *testing.T) {
addr, closer := newStallListener(t)
defer closer()
tr := timeoutTransport(nil)
tr.ResponseHeaderTimeout = 2 * time.Second
client := &http.Client{Transport: &userAgentTransport{base: tr}}
start := time.Now()
_, err := client.Get("http://" + addr + "/")
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected a timeout error from stalled server, got nil")
}
if elapsed > 10*time.Second {
t.Errorf("request took %v; ResponseHeaderTimeout did not fire", elapsed)
}
t.Logf("request failed as expected after %v: %v", elapsed, err)
}
-82
View File
@@ -1,82 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"os"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
"golang.org/x/term"
)
// ShowCommentsMaybeInteractive fetches & prints comments, depending on the --comments flag.
// If that flag is unset, and output is not piped, prompts the user first.
func ShowCommentsMaybeInteractive(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, totalComments int) error {
if ctx.Bool("comments") {
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
c := ctx.Login.Client()
comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, opts)
if err != nil {
return err
}
print.Comments(comments)
} else if print.IsInteractive() && !ctx.IsSet("comments") {
// if we're interactive, but --comments hasn't been explicitly set to false
if err := ShowCommentsPaginated(requestCtx, ctx, idx, totalComments); err != nil {
fmt.Printf("error while loading comments: %v\n", err)
}
}
return nil
}
// ShowCommentsPaginated prompts if issue/pr comments should be shown and continues to do so.
func ShowCommentsPaginated(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, totalComments int) error {
c := ctx.Login.Client()
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
prompt := "show comments?"
commentsLoaded := 0
// paginated fetch
// NOTE: as of gitea 1.13, pagination is not provided by this endpoint, but handles
// this function gracefully anyways.
for {
loadComments := true
if err := huh.NewConfirm().
Title(prompt).
Value(&loadComments).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
} else if !loadComments {
break
} else {
if comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, opts); err != nil {
return err
} else if len(comments) != 0 {
print.Comments(comments)
commentsLoaded += len(comments)
}
if commentsLoaded >= totalComments {
break
}
opts.ListOptions.Page++
prompt = "load more?"
}
}
return nil
}
// IsStdinPiped checks if stdin is piped
func IsStdinPiped() bool {
return !term.IsTerminal(int(os.Stdin.Fd()))
}
-218
View File
@@ -1,218 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// IsQuitting checks if the user has aborted the interactive prompt
func IsQuitting(err error) bool {
return err == huh.ErrUserAborted
}
// CreateIssue interactively creates an issue
func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) error {
owner, repo, err := promptRepoSlug(owner, repo)
if err != nil {
return err
}
printTitleAndContent("Target repo:", owner+"/"+repo)
var opts gitea.CreateIssueOption
if err := promptIssueProperties(ctx, login, owner, repo, &opts); err != nil {
return err
}
issue, err := task.CreateIssue(ctx, login, owner, repo, opts)
if err != nil {
return err
}
print.IssueDetails(issue, nil)
fmt.Println(issue.HTMLURL)
return nil
}
func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error {
var milestoneName string
var err error
selectableChan := make(chan issueSelectables, 1)
go fetchIssueSelectables(ctx, login, owner, repo, selectableChan)
// title
if err := huh.NewInput().
Title("Issue title:").
Value(&o.Title).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue title:", o.Title)
// description
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Issue description(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&o.Body),
),
).WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue description(markdown):", o.Body)
// wait until selectables are fetched
selectables := <-selectableChan
if selectables.Err != nil {
return selectables.Err
}
// skip remaining props if we don't have permission to set them
if !selectables.Repo.Permissions.Push {
return nil
}
// assignees
if o.Assignees, err = promptMultiSelect("Assignees:", selectables.Assignees, "[other]"); err != nil {
return err
}
printTitleAndContent("Assignees:", strings.Join(o.Assignees, "\n"))
// milestone
if len(selectables.MilestoneList) != 0 {
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", ""); err != nil {
return err
}
o.Milestone = selectables.MilestoneMap[milestoneName]
printTitleAndContent("Milestone:", milestoneName)
}
// labels
if len(selectables.LabelList) != 0 {
options := make([]huh.Option[int64], 0, len(selectables.LabelList))
labelsMap := make(map[int64]string, len(selectables.LabelList))
for _, l := range selectables.LabelList {
options = append(options, huh.Option[int64]{Key: l, Value: selectables.LabelMap[l]})
labelsMap[selectables.LabelMap[l]] = l
}
if err := huh.NewMultiSelect[int64]().
Title("Labels:").
Options(options...).
Value(&o.Labels).
Run(); err != nil {
return err
}
var labels []string
for _, labelID := range o.Labels {
labels = append(labels, labelsMap[labelID])
}
printTitleAndContent("Labels:", strings.Join(labels, "\n"))
}
// deadline
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
return err
}
deadlineStr := "No due date"
if o.Deadline != nil && !o.Deadline.IsZero() {
deadlineStr = o.Deadline.Format("2006-01-02")
}
printTitleAndContent("Due date:", deadlineStr)
return nil
}
type issueSelectables struct {
Repo *gitea.Repository
Assignees []string
MilestoneList []string
MilestoneMap map[string]int64
LabelList []string
LabelMap map[string]int64
Err error
}
func fetchIssueSelectables(ctx context.Context, login *config.Login, owner, repo string, done chan issueSelectables) {
// TODO PERF make these calls concurrent
r := issueSelectables{}
c := login.Client()
r.Repo, _, r.Err = c.Repositories.GetRepo(ctx, owner, repo)
if r.Err != nil {
done <- r
return
}
// we can set the following properties only if we have write access to the repo
// so we fastpath this if not.
if !r.Repo.Permissions.Push {
done <- r
return
}
assignees, _, err := c.Repositories.GetAssignees(ctx, owner, repo)
if err != nil {
r.Err = err
done <- r
return
}
r.Assignees = make([]string, len(assignees))
for i, u := range assignees {
r.Assignees[i] = u.UserName
}
milestones, _, err := c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{})
if err != nil {
r.Err = err
done <- r
return
}
r.MilestoneMap = make(map[string]int64)
r.MilestoneList = make([]string, len(milestones))
for i, m := range milestones {
r.MilestoneMap[m.Title] = m.ID
r.MilestoneList[i] = m.Title
}
r.LabelMap = make(map[string]int64)
r.LabelList = make([]string, 0)
for page := 1; ; {
labels, resp, err := c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
r.Err = err
done <- r
return
}
for _, l := range labels {
r.LabelMap[l.Name] = l.ID
r.LabelList = append(r.LabelList, l.Name)
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
done <- r
}
-193
View File
@@ -1,193 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"slices"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// EditIssue interactively edits an issue
func EditIssue(requestCtx stdctx.Context, ctx context.TeaContext, index int64) (*task.EditIssueOption, error) {
opts := task.EditIssueOption{}
var err error
ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo)
if err != nil {
return &opts, err
}
printTitleAndContent("Target repo:", ctx.Owner+"/"+ctx.Repo)
c := ctx.Login.Client()
i, _, err := c.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, index)
if err != nil {
return &opts, err
}
opts = task.EditIssueOption{
Index: index,
Title: &i.Title,
Body: &i.Body,
Deadline: i.Deadline,
}
i.Assignees = cleanAssignees(i.Assignees)
if len(i.Assignees) != 0 {
for _, a := range i.Assignees {
opts.SetAssignees = append(opts.SetAssignees, a.UserName)
}
}
if len(i.Labels) != 0 {
for _, l := range i.Labels {
opts.AddLabels = append(opts.AddLabels, l.Name)
}
}
if i.Milestone != nil {
opts.Milestone = &i.Milestone.Title
}
if err := promptIssueEditProperties(requestCtx, &ctx, &opts); err != nil {
return &opts, err
}
return &opts, err
}
func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContext, o *task.EditIssueOption) error {
var milestoneName string
var labelsSelected []string
var err error
selectableChan := make(chan issueSelectables, 1)
go fetchIssueSelectables(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, selectableChan)
// title
if err := huh.NewInput().
Title("Issue title:").
Value(o.Title).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue title:", *o.Title)
// description
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Issue description(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(o.Body),
),
).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Issue description(markdown):", *o.Body)
// wait until selectables are fetched
selectables := <-selectableChan
if selectables.Err != nil {
return selectables.Err
}
// skip remaining props if we don't have permission to set them
if !selectables.Repo.Permissions.Push {
return nil
}
currAssignees := o.SetAssignees
newAssignees := selectables.Assignees
for _, c := range currAssignees {
if i := slices.Index(newAssignees, c); i != -1 {
newAssignees = slices.Delete(newAssignees, i, i+1)
}
}
// assignees
if currAssignees, err = promptMultiSelectWithPreselect("Set Assignees:", currAssignees, newAssignees, "[other]"); err != nil {
return err
}
if len(currAssignees) == 0 && len(o.SetAssignees) > 0 {
o.RemoveAssignees = o.SetAssignees
}
o.SetAssignees = currAssignees
printTitleAndContent("Assignees:", strings.Join(o.SetAssignees, "\n"))
// milestone
if len(selectables.MilestoneList) != 0 {
var defaultMS string
if o.Milestone != nil {
defaultMS = *o.Milestone
}
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", defaultMS); err != nil {
return err
}
o.Milestone = &milestoneName
printTitleAndContent("Milestone:", milestoneName)
}
// labels
if len(selectables.LabelList) != 0 {
copy(labelsSelected, o.AddLabels)
if err := huh.NewMultiSelect[string]().
Title("Labels:").
Options(huh.NewOptions(selectables.LabelList...)...).
Value(&labelsSelected).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Labels:", strings.Join(labelsSelected, "\n"))
// removed labels
for _, l := range o.AddLabels {
if !slices.Contains(labelsSelected, l) {
o.RemoveLabels = append(o.RemoveLabels, l)
}
}
// added labels
o.AddLabels = make([]string, len(labelsSelected))
for i, l := range labelsSelected {
o.AddLabels[i] = l
}
}
// deadline
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
return err
}
deadlineStr := "No due date"
if o.Deadline != nil && !o.Deadline.IsZero() {
deadlineStr = o.Deadline.Format("2006-01-02")
}
printTitleAndContent("Due date:", deadlineStr)
return nil
}
func cleanAssignees(list []*gitea.User) []*gitea.User {
out := make([]*gitea.User, 0, len(list))
for _, a := range list {
if strings.TrimSpace(a.UserName) != "" {
out = append(out, a)
}
}
return out
}
-328
View File
@@ -1,328 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/auth"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// CreateLogin create an login interactive
func CreateLogin(ctx context.Context) error {
var (
name, token, user, passwd, otp, scopes, sshKey, sshCertPrincipal, sshKeyFingerprint string
insecure, sshAgent, versionCheck, helper bool
)
versionCheck = true
helper = false
giteaURL := "https://gitea.com"
if err := huh.NewInput().
Title("URL of Gitea instance: ").
Value(&giteaURL).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if len(s) == 0 {
return fmt.Errorf("URL is required")
}
_, err := url.Parse(s)
if err != nil {
return fmt.Errorf("invalid URL: %v", err)
}
return nil
}).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("URL of Gitea instance: ", giteaURL)
giteaURL = strings.TrimSuffix(strings.TrimSpace(giteaURL), "/")
name, err := task.GenerateLoginName(giteaURL, "")
if err != nil {
return err
}
validateFunc := func(s string) error {
if err := huh.ValidateNotEmpty()(s); err != nil {
return err
}
logins, err := config.GetLogins()
if err != nil {
return err
}
for _, login := range logins {
if login.Name == name {
return fmt.Errorf("login with name '%s' already exists", name)
}
}
return nil
}
if err := huh.NewInput().
Title("Name of new Login: ").
Value(&name).
Validate(validateFunc).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Name of new Login: ", name)
loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate", "oauth"})
if err != nil {
return err
}
printTitleAndContent("Login with: ", loginMethod)
switch loginMethod {
case "oauth":
if err := huh.NewConfirm().
Title("Allow Insecure connections:").
Value(&insecure).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
return auth.OAuthLoginWithOptions(ctx, name, giteaURL, insecure)
default: // token
var hasToken bool
if err := huh.NewConfirm().
Title("Do you have an access token?").
Value(&hasToken).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Do you have an access token?", strconv.FormatBool(hasToken))
if hasToken {
if err := huh.NewInput().
Title("Token:").
Value(&token).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Token:", token)
} else {
if err := huh.NewInput().
Title("Username:").
Value(&user).
Validate(huh.ValidateNotEmpty()).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Username:", user)
if err := huh.NewInput().
Title("Password:").
Value(&passwd).
Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Password:", "********")
var tokenScopes []string
if err := huh.NewMultiSelect[string]().
Title("Token Scopes:").
Options(huh.NewOptions(tokenScopeOpts...)...).
Value(&tokenScopes).
Validate(func(s []string) error {
if len(s) == 0 {
return errors.New("at least one scope is required")
}
return nil
}).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Token Scopes:", strings.Join(tokenScopes, "\n"))
scopes = strings.Join(tokenScopes, ",")
// Ask for OTP last so it's less likely to timeout
if err := huh.NewInput().
Title("OTP (if applicable):").
Value(&otp).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("OTP (if applicable):", otp)
}
case "ssh-key/certificate":
if err := huh.NewInput().
Title("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):").
Value(&sshKey).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):", sshKey)
if sshKey == "" {
pubKeys := task.ListSSHPubkey()
if len(pubKeys) == 0 {
fmt.Println("No SSH keys found in ~/.ssh or ssh-agent")
return nil
}
sshKey, err = promptSelect("Select ssh-key: ", pubKeys, "", "", "")
if err != nil {
return err
}
printTitleAndContent("Selected ssh-key:", sshKey)
sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey)
if err != nil {
return err
}
}
}
var optSettings bool
if err := huh.NewConfirm().
Title("Set Optional settings:").
Value(&optSettings).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Set Optional settings:", strconv.FormatBool(optSettings))
if optSettings {
if err := huh.NewInput().
Title("SSH Key Path (leave empty for auto-discovery):").
Value(&sshKey).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("SSH Key Path (leave empty for auto-discovery):", sshKey)
if err := huh.NewConfirm().
Title("Allow Insecure connections:").
Value(&insecure).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
if err := huh.NewConfirm().
Title("Add git helper:").
Value(&helper).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Add git helper:", strconv.FormatBool(helper))
if err := huh.NewConfirm().
Title("Check version of Gitea instance:").
Value(&versionCheck).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Check version of Gitea instance:", strconv.FormatBool(versionCheck))
}
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)
}
func parseSSHPubkeySelection(display string) (sshKey, sshCertPrincipal, sshKeyFingerprint string, sshAgent bool, err error) {
if strings.Contains(display, "principals") {
if sshCertPrincipal, err = regexpSubmatch(regexp.MustCompile(`.*?principals: (.*?)[,|\s]`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate principal from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", sshCertPrincipal, "", true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, "-cert.pub"), sshCertPrincipal, "", false, nil
}
if sshKeyFingerprint, err = regexpSubmatch(regexp.MustCompile(`(SHA256:.*?)\s`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key fingerprint from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", "", sshKeyFingerprint, true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, ".pub"), "", sshKeyFingerprint, false, nil
}
func regexpSubmatch(re *regexp.Regexp, s string) (string, error) {
match := re.FindStringSubmatch(s)
if len(match) < 2 {
return "", fmt.Errorf("no match")
}
return match[1], nil
}
var tokenScopeOpts = []string{
string(gitea.AccessTokenScopeAll),
string(gitea.AccessTokenScopeRepo),
string(gitea.AccessTokenScopeRepoStatus),
string(gitea.AccessTokenScopePublicRepo),
string(gitea.AccessTokenScopeAdminOrg),
string(gitea.AccessTokenScopeWriteOrg),
string(gitea.AccessTokenScopeReadOrg),
string(gitea.AccessTokenScopeAdminPublicKey),
string(gitea.AccessTokenScopeWritePublicKey),
string(gitea.AccessTokenScopeReadPublicKey),
string(gitea.AccessTokenScopeAdminRepoHook),
string(gitea.AccessTokenScopeWriteRepoHook),
string(gitea.AccessTokenScopeReadRepoHook),
string(gitea.AccessTokenScopeAdminOrgHook),
string(gitea.AccessTokenScopeAdminUserHook),
string(gitea.AccessTokenScopeNotification),
string(gitea.AccessTokenScopeUser),
string(gitea.AccessTokenScopeReadUser),
string(gitea.AccessTokenScopeUserEmail),
string(gitea.AccessTokenScopeUserFollow),
string(gitea.AccessTokenScopeDeleteRepo),
string(gitea.AccessTokenScopePackage),
string(gitea.AccessTokenScopeWritePackage),
string(gitea.AccessTokenScopeReadPackage),
string(gitea.AccessTokenScopeDeletePackage),
string(gitea.AccessTokenScopeAdminGPGKey),
string(gitea.AccessTokenScopeWriteGPGKey),
string(gitea.AccessTokenScopeReadGPGKey),
string(gitea.AccessTokenScopeAdminApplication),
string(gitea.AccessTokenScopeWriteApplication),
string(gitea.AccessTokenScopeReadApplication),
string(gitea.AccessTokenScopeSudo),
}
-69
View File
@@ -1,69 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSSHPubkeySelection(t *testing.T) {
tests := []struct {
name string
display string
wantSSHKey string
wantCertPrincipal string
wantKeyFingerprint string
wantSSHAgent bool
wantErr bool
}{
{
name: "local ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (/home/user/.ssh/id_ed25519.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantKeyFingerprint: "SHA256:abc",
},
{
name: "agent ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (ssh-agent)",
wantKeyFingerprint: "SHA256:abc",
wantSSHAgent: true,
},
{
name: "local certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1,user2 (/home/user/.ssh/id_ed25519-cert.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantCertPrincipal: "user1",
},
{
name: "agent certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1 (ssh-agent)",
wantCertPrincipal: "user1",
wantSSHAgent: true,
},
{
name: "unexpected display",
display: "ssh-ed25519 comment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sshKey, certPrincipal, keyFingerprint, sshAgent, err := parseSSHPubkeySelection(tt.display)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantSSHKey, sshKey)
assert.Equal(t, tt.wantCertPrincipal, certPrincipal)
assert.Equal(t, tt.wantKeyFingerprint, keyFingerprint)
assert.Equal(t, tt.wantSSHAgent, sshAgent)
})
}
}
-70
View File
@@ -1,70 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// CreateMilestone interactively creates a milestone
func CreateMilestone(requestCtx stdctx.Context, login *config.Login, owner, repo string) error {
var title, description, deadline string
// owner, repo
owner, repo, err := promptRepoSlug(owner, repo)
if err != nil {
return err
}
printTitleAndContent("Target repo:", fmt.Sprintf("%s/%s", owner, repo))
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Milestone title:").
Validate(huh.ValidateNotEmpty()).
Value(&title),
huh.NewText().
Title("Milestone description(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&description),
huh.NewInput().
Title("Milestone deadline:").
Placeholder("YYYY-MM-DD").
Validate(func(s string) error {
if s == "" {
return nil // no deadline
}
_, err := time.Parse("2006-01-02", s)
return err
}).
Value(&deadline),
),
).WithTheme(theme.GetTheme()).Run(); err != nil {
return err
}
var deadlineTM *time.Time
if deadline != "" {
tm, _ := time.Parse("2006-01-02", deadline)
deadlineTM = &tm
}
return task.CreateMilestone(requestCtx, login,
owner,
repo,
title,
description,
deadlineTM,
gitea.StateOpen)
}
-21
View File
@@ -1,21 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"fmt"
"gitea.dev/tea/modules/theme"
"charm.land/lipgloss/v2"
)
// printTitleAndContent prints a title and content with the gitea theme
func printTitleAndContent(title, content string) {
hasDarkBG := theme.HasDarkBackground()
style := lipgloss.NewStyle().
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
Padding(0, 1)
fmt.Print(style.Render(title), content+"\n")
}
-201
View File
@@ -1,201 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"fmt"
"slices"
"strings"
"time"
"gitea.dev/tea/modules/theme"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
)
// PromptPassword asks for a password and blocks until input was made.
func PromptPassword(name string) (pass string, err error) {
err = huh.NewInput().
Title(name + " password:").
Validate(huh.ValidateNotEmpty()).EchoMode(huh.EchoModePassword).
Value(&pass).
WithTheme(theme.GetTheme()).
Run()
return
}
// promptRepoSlug interactively prompts for a Gitea repository or returns the current one
func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err error) {
prompt := "Target repo:"
defaultVal := ""
required := true
if len(defaultOwner) != 0 && len(defaultRepo) != 0 {
defaultVal = fmt.Sprintf("%s/%s", defaultOwner, defaultRepo)
required = false
}
var repoSlug string
owner = defaultOwner
repo = defaultRepo
repoSlug = defaultVal
err = huh.NewInput().
Title(prompt).
Value(&repoSlug).
Validate(func(str string) error {
if !required && len(str) == 0 {
return nil
}
split := strings.Split(str, "/")
if len(split) != 2 || len(split[0]) == 0 || len(split[1]) == 0 {
return fmt.Errorf("must follow the <owner>/<repo> syntax")
}
return nil
}).WithTheme(theme.GetTheme()).Run()
if err == nil && len(repoSlug) != 0 {
repoSlugSplit := strings.Split(repoSlug, "/")
owner = repoSlugSplit[0]
repo = repoSlugSplit[1]
}
return
}
// promptDatetime prompts for a date or datetime string.
// Supports all formats understood by araddon/dateparse.
func promptDatetime(prompt string) (val *time.Time, err error) {
var date string
if err := huh.NewInput().
Title(prompt).
Placeholder("YYYY-MM-DD").
Validate(func(s string) error {
if s == "" {
return nil
}
_, err := time.Parse("2006-01-02", s)
return err
}).
Value(&date).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, err
}
if date == "" {
return nil, nil // no date
}
t, _ := time.Parse("2006-01-02", date)
return &t, nil
}
// promptSelect creates a generic multiselect prompt, with processing of custom values.
func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) {
opts := huh.NewOptions(makeSelectOpts(options, customVal, "")...)
return runMultiSelect(prompt, opts, customVal)
}
// promptMultiSelectWithPreselect creates a generic multiselect prompt with preselected values and processing of custom values.
func promptMultiSelectWithPreselect(prompt string, selected []string, options []string, customVal string) ([]string, error) {
opts := make([]huh.Option[string], 0, len(selected)+len(options)+1)
for _, name := range selected {
opts = append(opts, huh.NewOption(name, name).Selected(true))
}
opts = append(opts, huh.NewOptions(makeSelectOpts(options, customVal, "")...)...)
return runMultiSelect(prompt, opts, customVal)
}
func runMultiSelect(prompt string, opts []huh.Option[string], customVal string) ([]string, error) {
var selection []string
if err := huh.NewMultiSelect[string]().
Title(prompt).
Options(opts...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, err
}
return promptCustomVal(prompt, customVal, selection)
}
// promptSelectV2 creates a generic select prompt
func promptSelectV2(prompt string, options []string) (string, error) {
if len(options) == 0 {
return "", nil
}
selection := options[0]
if err := huh.NewSelect[string]().
Title(prompt).
Options(huh.NewOptions(options...)...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return "", err
}
return selection, nil
}
// promptSelect creates a generic select prompt, with processing of custom values or none-option.
func promptSelect(prompt string, options []string, customVal, noneVal, defaultVal string) (string, error) {
var selection string
if defaultVal == "" && noneVal != "" {
defaultVal = noneVal
}
if len(options) > 0 && !slices.Contains(options, defaultVal) {
defaultVal = options[0]
}
selection = defaultVal
if err := huh.NewSelect[string]().
Title(prompt).
Options(huh.NewOptions(makeSelectOpts(options, customVal, noneVal)...)...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return "", err
}
if noneVal != "" && selection == noneVal {
return "", nil
}
if customVal != "" {
sel, err := promptCustomVal(prompt, customVal, []string{selection})
if err != nil {
return "", err
}
selection = sel[0]
}
return selection, nil
}
// makeSelectOpts adds cusotmVal & noneVal to opts if set.
func makeSelectOpts(opts []string, customVal, noneVal string) []string {
if customVal != "" {
opts = append(opts, customVal)
}
if noneVal != "" {
opts = append(opts, noneVal)
}
return opts
}
// promptCustomVal checks if customVal is present in selection, and prompts
// for custom input to add to the selection instead.
func promptCustomVal(prompt, customVal string, selection []string) ([]string, error) {
// check for custom value & prompt again with text input
if otherIndex := utils.IndexOf(selection, customVal); otherIndex != -1 {
var customAssignees string
if err := huh.NewInput().
Title(prompt).
Description("comma separated list").
Value(&customAssignees).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return nil, err
}
selection = append(selection[:otherIndex], selection[otherIndex+1:]...)
selection = append(selection, strings.Split(customAssignees, ",")...)
}
return selection, nil
}
-152
View File
@@ -1,152 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
// CreatePull interactively creates a PR
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error) {
var (
base, head string
allowMaintainerEdits = true
agit bool
)
// owner, repo
if ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo); err != nil {
return err
}
// base
if base, err = task.GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo); err != nil {
return err
}
var headOwner, headBranch string
validator := huh.ValidateNotEmpty()
if ctx.LocalRepo != nil {
headOwner, headBranch, err = task.GetDefaultPRHead(ctx.LocalRepo)
if err == nil {
validator = func(string) error { return nil }
}
}
if err := huh.NewConfirm().
Title("Do you want to create an agit flow pull request?").
Value(&agit).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
if agit {
var (
topic string
baseRemote string
)
topic = headBranch
head = "HEAD"
baseRemote = "origin"
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Target branch:").
Value(&base).
Validate(huh.ValidateNotEmpty()),
huh.NewInput().
Title("Source repo remote:").
Value(&baseRemote),
huh.NewInput().
Title("Topic branch:").
Value(&topic).
Validate(validator),
huh.NewInput().
Title("Head branch:").
Value(&head).
Validate(validator),
),
).Run(); err != nil {
return err
}
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
if err = promptIssueProperties(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
return err
}
return task.CreateAgitFlowPull(
requestCtx,
ctx,
baseRemote,
head,
base,
topic,
&opts,
PromptPassword,
)
}
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Target branch:").
Value(&base).
Validate(huh.ValidateNotEmpty()),
huh.NewInput().
Title("Source repo owner:").
Value(&headOwner),
huh.NewInput().
Title("Source branch:").
Value(&headBranch).
Validate(validator),
huh.NewConfirm().
Title("Allow maintainers to push to the base branch:").
Value(&allowMaintainerEdits),
),
).Run(); err != nil {
return err
}
head = task.GetHeadSpec(headOwner, headBranch, ctx.Owner)
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
if err = promptIssueProperties(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
return err
}
pr, err := task.CreatePull(
requestCtx,
ctx,
base,
head,
&allowMaintainerEdits,
&opts)
if err != nil {
return err
}
print.PullDetails(pr, nil, nil)
return nil
}
-108
View File
@@ -1,108 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
)
// MergePull interactively creates a PR
func MergePull(requestCtx stdctx.Context, ctx *context.TeaContext) error {
if ctx.LocalRepo == nil {
return fmt.Errorf("pull request index is required")
}
branch, _, err := ctx.LocalRepo.TeaGetCurrentBranchNameAndSHA()
if err != nil {
return err
}
idx, err := getPullIndex(requestCtx, ctx, branch)
if err != nil {
return err
}
return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{
Style: gitea.MergeStyle(ctx.String("style")),
Title: ctx.String("title"),
Message: ctx.String("message"),
})
}
// getPullIndex interactively determines the PR index
func getPullIndex(requestCtx stdctx.Context, ctx *context.TeaContext, branch string) (int64, error) {
c := ctx.Login.Client()
opts := gitea.ListPullRequestsOptions{
State: gitea.StateOpen,
ListOptions: flags.GetListOptions(ctx.Command),
}
selected := ""
loadMoreOption := "PR not found? Load more PRs..."
// paginated fetch
var prs []*gitea.PullRequest
for {
var err error
prs, _, err = c.PullRequests.ListRepoPullRequests(requestCtx, ctx.Owner, ctx.Repo, opts)
if err != nil {
return 0, err
}
if len(prs) == 0 {
return 0, fmt.Errorf("no open PRs found")
}
opts.ListOptions.Page++
prOptions := make([]string, 0)
// get the PR indexes where head branch is the current branch
for _, pr := range prs {
if pr.Head.Ref == branch {
prOptions = append(prOptions, fmt.Sprintf("#%d: %s", pr.Index, pr.Title))
}
}
// then get the PR indexes where base branch is the current branch
for _, pr := range prs {
// don't add the same PR twice, so `pr.Head.Ref != branch`
if pr.Base.Ref == branch && pr.Head.Ref != branch {
prOptions = append(prOptions, fmt.Sprintf("#%d: %s", pr.Index, pr.Title))
}
}
prOptions = append(prOptions, loadMoreOption)
if err := huh.NewSelect[string]().
Title("Select a PR to merge:").
Options(huh.NewOptions(prOptions...)...).
Value(&selected).
Filtering(true).
Run(); err != nil {
return 0, err
}
if selected != loadMoreOption {
break
}
}
// get the index from the selected option
before, _, _ := strings.Cut(selected, ":")
before = strings.TrimPrefix(before, "#")
idx, err := utils.ArgToIndex(before)
if err != nil {
return 0, err
}
return idx, nil
}
-100
View File
@@ -1,100 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
stdctx "context"
"fmt"
"os"
"strconv"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
"charm.land/huh/v2"
)
var reviewStates = map[string]gitea.ReviewStateType{
"approve": gitea.ReviewStateApproved,
"comment": gitea.ReviewStateComment,
"request changes": gitea.ReviewStateRequestChanges,
}
var reviewStateOptions = []string{"comment", "request changes", "approve"}
// ReviewPull interactively reviews a PR
func ReviewPull(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) error {
var state gitea.ReviewStateType
var comment string
var codeComments []gitea.CreatePullReviewComment
var err error
// codeComments
reviewDiff := true
if err := huh.NewConfirm().
Title("Review / comment the diff?").
Value(&reviewDiff).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Review / comment the diff?", strconv.FormatBool(reviewDiff))
if reviewDiff {
if codeComments, err = DoDiffReview(requestCtx, ctx, idx); err != nil {
fmt.Printf("Error during diff review: %s\n", err)
}
fmt.Printf("Found %d code comments in your review\n", len(codeComments))
}
// state
var stateString string
if err := huh.NewSelect[string]().
Title("Your assessment:").
Options(huh.NewOptions(reviewStateOptions...)...).
Value(&stateString).
WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
printTitleAndContent("Your assessment:", stateString)
state = reviewStates[stateString]
// comment
field := huh.NewText().
Title("Concluding comment(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&comment)
if (state == gitea.ReviewStateComment && len(codeComments) == 0) || state == gitea.ReviewStateRequestChanges {
field = field.Validate(huh.ValidateNotEmpty())
}
if err := huh.NewForm(huh.NewGroup(field)).WithTheme(theme.GetTheme()).Run(); err != nil {
return err
}
printTitleAndContent("Concluding comment(markdown):", comment)
return task.CreatePullReview(requestCtx, ctx, idx, state, comment, codeComments)
}
// DoDiffReview (1) fetches & saves diff in tempfile, (2) starts $VISUAL or $EDITOR to comment on diff,
// (3) parses resulting file into code comments.
// It doesn't really make sense to use survey.Editor() here, as we'd read the file content at least twice.
func DoDiffReview(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) ([]gitea.CreatePullReviewComment, error) {
tmpFile, err := task.SavePullDiff(requestCtx, ctx, idx)
if err != nil {
return nil, err
}
defer os.Remove(tmpFile)
if err = task.OpenFileInEditor(tmpFile); err != nil {
return nil, err
}
return task.ParseDiffComments(tmpFile)
}
-76
View File
@@ -1,76 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// ActionSecretsList prints a list of action secrets
func ActionSecretsList(secrets []*gitea.Secret, output string) error {
t := table{
headers: []string{
"Name",
"Created",
},
}
for _, secret := range secrets {
t.addRow(
secret.Name,
FormatTime(secret.Created, output != ""),
)
}
if len(secrets) == 0 {
fmt.Printf("No secrets found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
// ActionVariableDetails prints details of a specific action variable
func ActionVariableDetails(variable *gitea.RepoActionVariable) {
fmt.Printf("Name: %s\n", variable.Name)
fmt.Printf("Value: %s\n", variable.Value)
fmt.Printf("Repository ID: %d\n", variable.RepoID)
fmt.Printf("Owner ID: %d\n", variable.OwnerID)
}
// ActionVariablesList prints a list of action variables
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) error {
t := table{
headers: []string{
"Name",
"Value",
"Repository ID",
},
}
for _, variable := range variables {
// Truncate long values for table display
value := variable.Value
if len(value) > 50 {
value = value[:47] + "..."
}
t.addRow(
variable.Name,
value,
fmt.Sprintf("%d", variable.RepoID),
)
}
if len(variables) == 0 {
fmt.Printf("No variables found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
-215
View File
@@ -1,215 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"time"
"gitea.dev/sdk"
)
// formatDurationMinutes formats duration in a human-readable way
func formatDurationMinutes(started, completed time.Time) string {
if started.IsZero() {
return ""
}
end := completed
if end.IsZero() {
end = time.Now()
}
duration := end.Sub(started)
if duration < time.Minute {
return fmt.Sprintf("%ds", int(duration.Seconds()))
}
if duration < time.Hour {
return fmt.Sprintf("%dm", int(duration.Minutes()))
}
hours := int(duration.Hours())
minutes := int(duration.Minutes()) % 60
return fmt.Sprintf("%dh%dm", hours, minutes)
}
// getWorkflowDisplayName returns the display title or falls back to path
func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string {
if run.DisplayTitle != "" {
return run.DisplayTitle
}
return run.Path
}
// ActionRunsList prints a list of workflow runs
func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) error {
t := table{
headers: []string{
"ID",
"Status",
"Workflow",
"Branch",
"Event",
"Started",
"Duration",
},
}
machineReadable := isMachineReadable(output)
for _, run := range runs {
workflowName := getWorkflowDisplayName(run)
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
t.addRow(
fmt.Sprintf("%d", run.ID),
run.Status,
workflowName,
run.HeadBranch,
run.Event,
FormatTime(run.StartedAt, machineReadable),
duration,
)
}
if len(runs) == 0 {
fmt.Printf("No workflow runs found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
// ActionRunDetails prints detailed information about a workflow run
func ActionRunDetails(run *gitea.ActionWorkflowRun) {
workflowName := getWorkflowDisplayName(run)
fmt.Printf("Run ID: %d\n", run.ID)
fmt.Printf("Run Number: %d\n", run.RunNumber)
fmt.Printf("Status: %s\n", run.Status)
if run.Conclusion != "" {
fmt.Printf("Conclusion: %s\n", run.Conclusion)
}
fmt.Printf("Workflow: %s\n", workflowName)
fmt.Printf("Path: %s\n", run.Path)
fmt.Printf("Branch: %s\n", run.HeadBranch)
fmt.Printf("Event: %s\n", run.Event)
fmt.Printf("Head SHA: %s\n", run.HeadSha)
fmt.Printf("Started: %s\n", FormatTime(run.StartedAt, false))
if !run.CompletedAt.IsZero() {
fmt.Printf("Completed: %s\n", FormatTime(run.CompletedAt, false))
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
fmt.Printf("Duration: %s\n", duration)
}
if run.RunAttempt > 1 {
fmt.Printf("Attempt: %d\n", run.RunAttempt)
}
if run.Actor != nil {
fmt.Printf("Triggered by: %s\n", run.Actor.UserName)
}
if run.HTMLURL != "" {
fmt.Printf("URL: %s\n", run.HTMLURL)
}
}
// ActionWorkflowJobsList prints a list of workflow jobs
func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) error {
t := table{
headers: []string{
"ID",
"Name",
"Status",
"Runner",
"Started",
"Duration",
},
}
machineReadable := isMachineReadable(output)
for _, job := range jobs {
duration := formatDurationMinutes(job.StartedAt, job.CompletedAt)
runner := job.RunnerName
if runner == "" {
runner = "-"
}
t.addRow(
fmt.Sprintf("%d", job.ID),
job.Name,
job.Status,
runner,
FormatTime(job.StartedAt, machineReadable),
duration,
)
}
if len(jobs) == 0 {
fmt.Printf("No jobs found\n")
return nil
}
t.sort(0, true)
return t.print(output)
}
// ActionWorkflowsList prints a list of workflows from the workflow API
func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error {
t := table{
headers: []string{
"ID",
"Name",
"Path",
"State",
},
}
for _, wf := range workflows {
t.addRow(
wf.ID,
wf.Name,
wf.Path,
wf.State,
)
}
if len(workflows) == 0 {
fmt.Printf("No workflows found\n")
return nil
}
t.sort(1, true) // Sort by name column
return t.print(output)
}
// ActionWorkflowDetails prints detailed information about a workflow
func ActionWorkflowDetails(wf *gitea.ActionWorkflow) {
fmt.Printf("ID: %s\n", wf.ID)
fmt.Printf("Name: %s\n", wf.Name)
fmt.Printf("Path: %s\n", wf.Path)
fmt.Printf("State: %s\n", wf.State)
if wf.HTMLURL != "" {
fmt.Printf("URL: %s\n", wf.HTMLURL)
}
if wf.BadgeURL != "" {
fmt.Printf("Badge: %s\n", wf.BadgeURL)
}
if !wf.CreatedAt.IsZero() {
fmt.Printf("Created: %s\n", FormatTime(wf.CreatedAt, false))
}
if !wf.UpdatedAt.IsZero() {
fmt.Printf("Updated: %s\n", FormatTime(wf.UpdatedAt, false))
}
}
// ActionWorkflowDispatchResult prints the result of a workflow dispatch
func ActionWorkflowDispatchResult(details *gitea.RunDetails) {
fmt.Printf("Workflow dispatched successfully\n")
if details != nil {
fmt.Printf("Run ID: %d\n", details.WorkflowRunID)
if details.HTMLURL != "" {
fmt.Printf("URL: %s\n", details.HTMLURL)
}
}
}
-256
View File
@@ -1,256 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/require"
)
func TestActionRunsListEmpty(t *testing.T) {
// Test with empty runs - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionRunsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{}, ""))
}
func TestActionRunsListWithData(t *testing.T) {
runs := []*gitea.ActionWorkflowRun{
{
ID: 1,
Status: "success",
DisplayTitle: "Test Workflow",
HeadBranch: "main",
Event: "push",
StartedAt: time.Now().Add(-1 * time.Hour),
CompletedAt: time.Now().Add(-30 * time.Minute),
},
{
ID: 2,
Status: "in_progress",
Path: ".gitea/workflows/test.yml",
HeadBranch: "feature",
Event: "pull_request",
StartedAt: time.Now().Add(-10 * time.Minute),
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionRunsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionRunsList(runs, ""))
}
func TestActionRunDetails(t *testing.T) {
run := &gitea.ActionWorkflowRun{
ID: 123,
RunNumber: 42,
Status: "success",
Conclusion: "success",
DisplayTitle: "Build and Test",
Path: ".gitea/workflows/ci.yml",
HeadBranch: "main",
Event: "push",
HeadSha: "abc123def456",
StartedAt: time.Now().Add(-2 * time.Hour),
CompletedAt: time.Now().Add(-1 * time.Hour),
RunAttempt: 1,
Actor: &gitea.User{
UserName: "testuser",
},
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/123",
}
// Test that it doesn't panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionRunDetails panicked: %v", r)
}
}()
ActionRunDetails(run)
}
func TestActionWorkflowJobsListEmpty(t *testing.T) {
// Test with empty jobs - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowJobsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, ""))
}
func TestActionWorkflowJobsListWithData(t *testing.T) {
jobs := []*gitea.ActionWorkflowJob{
{
ID: 1,
Name: "build",
Status: "success",
RunnerName: "runner-1",
StartedAt: time.Now().Add(-30 * time.Minute),
CompletedAt: time.Now().Add(-20 * time.Minute),
},
{
ID: 2,
Name: "test",
Status: "in_progress",
RunnerName: "runner-2",
StartedAt: time.Now().Add(-5 * time.Minute),
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowJobsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
}
func TestActionWorkflowsListEmpty(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, ""))
}
func TestActionWorkflowsListWithData(t *testing.T) {
workflows := []*gitea.ActionWorkflow{
{
ID: "1",
Name: "CI",
Path: ".gitea/workflows/ci.yml",
State: "active",
},
{
ID: "2",
Name: "Deploy",
Path: ".gitea/workflows/deploy.yml",
State: "disabled_manually",
},
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList(workflows, ""))
}
func TestActionWorkflowDetails(t *testing.T) {
wf := &gitea.ActionWorkflow{
ID: "1",
Name: "CI Pipeline",
Path: ".gitea/workflows/ci.yml",
State: "active",
HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml",
BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg",
CreatedAt: time.Now().Add(-24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDetails panicked: %v", r)
}
}()
ActionWorkflowDetails(wf)
}
func TestActionWorkflowDispatchResult(t *testing.T) {
details := &gitea.RunDetails{
WorkflowRunID: 42,
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42",
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked: %v", r)
}
}()
ActionWorkflowDispatchResult(details)
}
func TestActionWorkflowDispatchResultNil(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r)
}
}()
ActionWorkflowDispatchResult(nil)
}
func TestFormatDurationMinutes(t *testing.T) {
now := time.Now()
tests := []struct {
name string
started time.Time
completed time.Time
expected string
}{
{
name: "zero started",
started: time.Time{},
completed: now,
expected: "",
},
{
name: "30 seconds",
started: now.Add(-30 * time.Second),
completed: now,
expected: "30s",
},
{
name: "5 minutes",
started: now.Add(-5 * time.Minute),
completed: now,
expected: "5m",
},
{
name: "in progress (no completed)",
started: now.Add(-1 * time.Hour),
completed: time.Time{},
expected: "1h0m",
},
{
name: "2 hours 30 minutes",
started: now.Add(-150 * time.Minute),
completed: now,
expected: "2h30m",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := formatDurationMinutes(test.started, test.completed)
if result != test.expected {
t.Errorf("formatDurationMinutes() = %q, want %q", result, test.expected)
}
})
}
}
-215
View File
@@ -1,215 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/require"
)
func TestActionSecretsListEmpty(t *testing.T) {
// Test with empty secrets - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionSecretsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionSecretsList([]*gitea.Secret{}, ""))
}
func TestActionSecretsListWithData(t *testing.T) {
secrets := []*gitea.Secret{
{
Name: "TEST_SECRET_1",
Created: time.Now().Add(-24 * time.Hour),
},
{
Name: "TEST_SECRET_2",
Created: time.Now().Add(-48 * time.Hour),
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionSecretsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionSecretsList(secrets, ""))
// Test JSON output format to verify structure
var buf bytes.Buffer
testTable := table{
headers: []string{"Name", "Created"},
}
for _, secret := range secrets {
testTable.addRow(secret.Name, FormatTime(secret.Created, true))
}
require.NoError(t, testTable.fprint(&buf, "json"))
output := buf.String()
if !strings.Contains(output, "TEST_SECRET_1") {
t.Error("Expected TEST_SECRET_1 in JSON output")
}
if !strings.Contains(output, "TEST_SECRET_2") {
t.Error("Expected TEST_SECRET_2 in JSON output")
}
}
func TestActionVariableDetails(t *testing.T) {
variable := &gitea.RepoActionVariable{
Name: "TEST_VARIABLE",
Value: "test_value",
RepoID: 123,
OwnerID: 456,
}
// Test that it doesn't panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariableDetails panicked: %v", r)
}
}()
ActionVariableDetails(variable)
}
func TestActionVariablesListEmpty(t *testing.T) {
// Test with empty variables - should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariablesList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{}, ""))
}
func TestActionVariablesListWithData(t *testing.T) {
variables := []*gitea.RepoActionVariable{
{
Name: "TEST_VARIABLE_1",
Value: "short_value",
RepoID: 123,
OwnerID: 456,
},
{
Name: "TEST_VARIABLE_2",
Value: strings.Repeat("a", 60), // Long value to test truncation
RepoID: 124,
OwnerID: 457,
},
}
// Test that it doesn't panic with real data
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariablesList panicked with data: %v", r)
}
}()
require.NoError(t, ActionVariablesList(variables, ""))
// Test JSON output format to verify structure and truncation
var buf bytes.Buffer
testTable := table{
headers: []string{"Name", "Value", "Repository ID"},
}
for _, variable := range variables {
value := variable.Value
if len(value) > 50 {
value = value[:47] + "..."
}
testTable.addRow(variable.Name, value, strconv.Itoa(int(variable.RepoID)))
}
require.NoError(t, testTable.fprint(&buf, "json"))
output := buf.String()
if !strings.Contains(output, "TEST_VARIABLE_1") {
t.Error("Expected TEST_VARIABLE_1 in JSON output")
}
if !strings.Contains(output, "TEST_VARIABLE_2") {
t.Error("Expected TEST_VARIABLE_2 in JSON output")
}
// Check that long value is truncated in our test table
if strings.Contains(output, strings.Repeat("a", 60)) {
t.Error("Long value should be truncated in table output")
}
}
func TestActionVariablesListValueTruncation(t *testing.T) {
variable := &gitea.RepoActionVariable{
Name: "LONG_VALUE_VARIABLE",
Value: strings.Repeat("abcdefghij", 10), // 100 characters
RepoID: 123,
OwnerID: 456,
}
// Test that it doesn't panic
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionVariablesList panicked with long value: %v", r)
}
}()
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{variable}, ""))
// Test the truncation logic directly
value := variable.Value
if len(value) > 50 {
value = value[:47] + "..."
}
if len(value) != 50 { // 47 chars + "..." = 50
t.Errorf("Truncated value should be 50 characters, got %d", len(value))
}
if !strings.HasSuffix(value, "...") {
t.Error("Truncated value should end with '...'")
}
}
func TestTableSorting(t *testing.T) {
// Test that the table sorting works correctly
secrets := []*gitea.Secret{
{Name: "Z_SECRET", Created: time.Now()},
{Name: "A_SECRET", Created: time.Now()},
{Name: "M_SECRET", Created: time.Now()},
}
// Test the table sorting logic
table := table{
headers: []string{"Name", "Created"},
}
for _, secret := range secrets {
table.addRow(secret.Name, FormatTime(secret.Created, true))
}
// Sort by first column (Name) in ascending order (false = ascending)
table.sort(0, false)
// Check that the first row is A_SECRET after ascending sorting
if table.values[0][0] != "A_SECRET" {
t.Errorf("Expected first sorted value to be 'A_SECRET', got '%s'", table.values[0][0])
}
// Check that the last row is Z_SECRET after ascending sorting
if table.values[2][0] != "Z_SECRET" {
t.Errorf("Expected last sorted value to be 'Z_SECRET', got '%s'", table.values[2][0])
}
}
-34
View File
@@ -1,34 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
func formatByteSize(size int64) string {
if size < 1024 {
return fmt.Sprintf("%d B", size)
}
return formatSize(size / 1024)
}
// ReleaseAttachmentsList prints a listing of release attachments
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) error {
t := tableWithHeader(
"Name",
"Size",
)
for _, attachment := range attachments {
t.addRow(
attachment.Name,
formatByteSize(attachment.Size),
)
}
return t.print(output)
}
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// BranchesList prints a listing of the branches
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) error {
printables := make([]printable, len(branches))
for i, branch := range branches {
var protection *gitea.BranchProtection
for _, p := range protections {
if p.BranchName == branch.Name {
protection = p
}
}
printables[i] = &printableBranch{branch, protection}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
type printableBranch struct {
branch *gitea.Branch
protection *gitea.BranchProtection
}
func (x printableBranch) FormatField(field string, machineReadable bool) string {
switch field {
case "name":
return x.branch.Name
case "protected":
return fmt.Sprintf("%t", x.branch.Protected)
case "user-can-merge":
return fmt.Sprintf("%t", x.branch.UserCanMerge)
case "user-can-push":
return fmt.Sprintf("%t", x.branch.UserCanPush)
case "protection":
if x.protection != nil {
approving := ""
for _, entry := range x.protection.ApprovalsWhitelistTeams {
approving += entry + "/"
}
for _, entry := range x.protection.ApprovalsWhitelistUsernames {
approving += entry + "/"
}
merging := ""
for _, entry := range x.protection.MergeWhitelistTeams {
merging += entry + "/"
}
for _, entry := range x.protection.MergeWhitelistUsernames {
merging += entry + "/"
}
pushing := ""
for _, entry := range x.protection.PushWhitelistTeams {
pushing += entry + "/"
}
for _, entry := range x.protection.PushWhitelistUsernames {
pushing += entry + "/"
}
return fmt.Sprintf(
"- enable-push: %t\n- approving: %s\n- merging: %s\n- pushing: %s\n",
x.protection.EnablePush, approving, merging, pushing,
)
}
return "<None>"
}
return ""
}
// BranchFields are all available fields to print with BranchesList()
var BranchFields = []string{
"name",
"protected",
"user-can-merge",
"user-can-push",
"protection",
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
)
func TestPrintableBranchProtectionUsesSeparateWhitelists(t *testing.T) {
protection := &gitea.BranchProtection{
EnablePush: true,
ApprovalsWhitelistTeams: []string{"approve-team"},
ApprovalsWhitelistUsernames: []string{"approve-user"},
MergeWhitelistTeams: []string{"merge-team"},
MergeWhitelistUsernames: []string{"merge-user"},
PushWhitelistTeams: []string{"push-team"},
PushWhitelistUsernames: []string{"push-user"},
}
result := printableBranch{
branch: &gitea.Branch{Name: "main"},
protection: protection,
}.FormatField("protection", false)
assert.Contains(t, result, "- approving: approve-team/approve-user/")
assert.Contains(t, result, "- merging: merge-team/merge-user/")
assert.Contains(t, result, "- pushing: push-team/push-user/")
assert.NotContains(t, result, "- approving: approve-team/approve-user/merge-team/")
}
-99
View File
@@ -1,99 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
)
// Comments renders a list of comments to stdout
func Comments(comments []*gitea.Comment) {
var baseURL string
if len(comments) != 0 {
baseURL = getRepoURL(comments[0].HTMLURL)
}
out := make([]string, len(comments))
for i, c := range comments {
out[i] = formatComment(c)
}
_ = outputMarkdown(fmt.Sprintf(
// this will become a heading by means of the first --- from a comment
"Comments\n%s",
strings.Join(out, "\n"),
), baseURL)
}
// CommentsList prints comments in tabular form, including IDs so they can be
// passed to 'tea comments edit' / 'tea comments delete'.
func CommentsList(comments []*gitea.Comment, output string) error {
if len(comments) == 0 {
fmt.Println("No comments found")
return nil
}
t := tableWithHeader(
"ID",
"Author",
"Created",
"Updated",
"Body",
)
for _, c := range comments {
updated := ""
if c.Updated.After(c.Created) {
updated = FormatTime(c.Updated, false)
}
t.addRow(
fmt.Sprintf("%d", c.ID),
"@"+c.Poster.UserName,
FormatTime(c.Created, false),
updated,
summarizeBody(c.Body),
)
}
return t.print(output)
}
func summarizeBody(body string) string {
const max = 80
var b strings.Builder
for _, r := range body {
if r == '\n' || r == '\r' {
b.WriteByte(' ')
} else {
b.WriteRune(r)
}
}
s := b.String()
if len(s) > max {
return s[:max-1] + "…"
}
return s
}
// Comment renders a comment to stdout
func Comment(c *gitea.Comment) {
_ = outputMarkdown(formatComment(c), getRepoURL(c.HTMLURL))
}
func formatComment(c *gitea.Comment) string {
edited := ""
if c.Updated.After(c.Created) {
edited = fmt.Sprintf(" *(edited on %s)*", FormatTime(c.Updated, false))
}
return fmt.Sprintf(
"---\n\n**@%s** wrote on %s%s:\n\n%s\n",
c.Poster.UserName,
FormatTime(c.Created, false),
edited,
c.Body,
)
}
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"os"
"regexp"
"time"
"gitea.dev/sdk"
"github.com/muesli/termenv"
"golang.org/x/term"
)
// IsInteractive checks if the output is piped, but NOT if the session is run interactively..
func IsInteractive() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// captures the repo URL part <host>/<owner>/<repo> of an url
var repoURLRegex = regexp.MustCompile("^([[:alnum:]]+://[^/]+(?:/[[:alnum:]]+){2})/.*")
func getRepoURL(resourceURL string) string {
return repoURLRegex.ReplaceAllString(resourceURL, "$1/")
}
// formatSize get kb in int and return string
func formatSize(kb int64) string {
if kb < 1024 {
return fmt.Sprintf("%d KB", kb)
}
mb := kb / 1024
if mb < 1024 {
return fmt.Sprintf("%d MB", mb)
}
gb := mb / 1024
if gb < 1024 {
return fmt.Sprintf("%d GB", gb)
}
return fmt.Sprintf("%d TB", gb/1024)
}
// FormatTime provides a string for the given time value.
// If machineReadable is set, a UTC RFC3339 string is returned,
// otherwise a simplified string in local time is used.
func FormatTime(t time.Time, machineReadable bool) string {
if t.IsZero() {
return ""
}
if machineReadable {
return t.UTC().Format(time.RFC3339)
}
location, err := time.LoadLocation("Local")
if err != nil {
return t.Format("2006-01-02 15:04 UTC")
}
return t.In(location).Format("2006-01-02 15:04")
}
func formatDuration(seconds int64, outputType string) string {
if isMachineReadable(outputType) {
return fmt.Sprint(seconds)
}
return time.Duration(1e9 * seconds).String()
}
func formatLabel(label *gitea.Label, allowColor bool, text string) string {
colorProfile := termenv.Ascii
if allowColor {
colorProfile = termenv.EnvColorProfile()
}
if len(text) == 0 {
text = label.Name
}
styled := termenv.String(text)
styled = styled.Foreground(colorProfile.Color("#" + label.Color))
return fmt.Sprint(styled)
}
func formatPermission(p *gitea.Permission) string {
if p.Admin {
return "admin"
} else if p.Push {
return "write"
}
return "read"
}
func formatUserName(u *gitea.User) string {
if len(u.FullName) == 0 {
return u.UserName
}
return u.FullName
}
func formatBoolean(b bool, allowIcons bool) string {
if !allowIcons {
return fmt.Sprintf("%v", b)
}
styled := "✔"
if !b {
styled = "✖"
}
return styled
}
-156
View File
@@ -1,156 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
"github.com/enescakir/emoji"
)
// IssueDetails print an issue rendered to stdout
func IssueDetails(issue *gitea.Issue, reactions []*gitea.Reaction) {
out := fmt.Sprintf(
"# #%d %s (%s)\n@%s created %s\n\n%s\n",
issue.Index,
issue.Title,
issue.State,
issue.Poster.UserName,
FormatTime(issue.Created, false),
issue.Body,
)
if len(reactions) > 0 {
out += fmt.Sprintf("\n---\n\n%s\n", formatReactions(reactions))
}
_ = outputMarkdown(out, getRepoURL(issue.HTMLURL))
}
func formatReactions(reactions []*gitea.Reaction) string {
reactionCounts := make(map[string]uint16)
for _, r := range reactions {
reactionCounts[r.Reaction]++
}
reactionStrings := make([]string, 0, len(reactionCounts))
for reaction, count := range reactionCounts {
reactionStrings = append(reactionStrings, fmt.Sprintf("%dx :%s:", count, reaction))
}
return emoji.Parse(strings.Join(reactionStrings, " | "))
}
// IssuesPullsList prints a listing of issues & pulls
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) error {
return printIssues(issues, output, fields)
}
// IssueFields are all available fields to print with IssuesList()
var IssueFields = []string{
"index",
"state",
"kind",
"author",
"author-id",
"url",
"title",
"body",
"created",
"updated",
"deadline",
"assignees",
"milestone",
"labels",
"comments",
"owner",
"repo",
}
func printIssues(issues []*gitea.Issue, output string, fields []string) error {
labelMap := map[int64]string{}
printables := make([]printable, len(issues))
machineReadable := isMachineReadable(output)
for i, x := range issues {
// pre-serialize labels for performance
for _, label := range x.Labels {
if _, ok := labelMap[label.ID]; !ok {
labelMap[label.ID] = formatLabel(label, !machineReadable, "")
}
}
// store items with printable interface
printables[i] = &printableIssue{x, &labelMap}
}
t := tableFromItems(fields, printables, machineReadable)
return t.print(output)
}
type printableIssue struct {
*gitea.Issue
formattedLabels *map[int64]string
}
func (x printableIssue) FormatField(field string, machineReadable bool) string {
switch field {
case "index":
return fmt.Sprintf("%d", x.Index)
case "state":
return string(x.State)
case "kind":
if x.PullRequest != nil {
return "Pull"
}
return "Issue"
case "author":
return formatUserName(x.Poster)
case "author-id":
return x.Poster.UserName
case "url":
return x.HTMLURL
case "title":
return x.Title
case "body":
return x.Body
case "created":
return FormatTime(x.Created, machineReadable)
case "updated":
return FormatTime(x.Updated, machineReadable)
case "deadline":
if x.Deadline == nil {
return ""
}
return FormatTime(*x.Deadline, machineReadable)
case "milestone":
if x.Milestone != nil {
return x.Milestone.Title
}
return ""
case "labels":
labels := make([]string, len(x.Labels))
for i, l := range x.Labels {
labels[i] = (*x.formattedLabels)[l.ID]
}
return strings.Join(labels, " ")
case "assignees":
assignees := make([]string, len(x.Assignees))
for i, a := range x.Assignees {
assignees[i] = formatUserName(a)
}
return strings.Join(assignees, " ")
case "comments":
return fmt.Sprintf("%d", x.Comments)
case "owner":
return x.Repository.Owner
case "repo":
return x.Repository.Name
}
return ""
}
-37
View File
@@ -1,37 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"strconv"
"strings"
gitea "gitea.dev/sdk"
)
// LabelsList prints a listing of labels
func LabelsList(labels []*gitea.Label, output string) error {
t := tableWithHeader(
"Index",
"Color",
"Name",
"Description",
"Level",
)
for _, label := range labels {
level := "Repository"
if strings.Contains(label.URL, "/orgs/") {
level = "Organization"
}
t.addRow(
strconv.FormatInt(label.ID, 10),
formatLabel(label, !isMachineReadable(output), label.Color),
label.Name,
label.Description,
level,
)
}
return t.print(output)
}
-54
View File
@@ -1,54 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
"gitea.dev/tea/modules/config"
)
// LoginDetails print login entry to stdout
func LoginDetails(login *config.Login) {
in := fmt.Sprintf("# %s\n\n[@%s](%s/%s)\n",
login.Name,
login.User,
strings.TrimSuffix(login.URL, "/"),
login.User,
)
if len(login.SSHKey) != 0 {
in += fmt.Sprintf("\nSSH Key: '%s' via %s\n",
login.SSHKey,
login.SSHHost,
)
}
in += fmt.Sprintf("\nCreated: %s", time.Unix(login.Created, 0).Format(time.RFC822))
_ = outputMarkdown(in, "")
}
// LoginsList prints a listing of logins
func LoginsList(logins []config.Login, output string) error {
t := tableWithHeader(
"Name",
"URL",
"SSHHost",
"User",
"Default",
)
for _, l := range logins {
t.addRow(
l.Name,
l.URL,
l.GetSSHHost(),
l.User,
fmt.Sprint(l.Default),
)
}
return t.print(output)
}
-145
View File
@@ -1,145 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
)
// LoginStatus contains the authentication status of a single configured login.
type LoginStatus struct {
Name string
URL string
User string
Valid bool
AuthMethod string
TokenExpiry time.Time
Helper bool
Default bool
Error string
}
// LoginStatusFields are the available fields to print with LoginStatuses.
var LoginStatusFields = []string{
"name",
"url",
"user",
"valid",
"auth_method",
"token_expiry",
"helper",
"default",
}
// LoginStatuses prints authentication status for one or more logins.
func LoginStatuses(statuses []LoginStatus, output string) error {
if output != "" {
printables := make([]printable, len(statuses))
for i := range statuses {
printables[i] = statuses[i]
}
t := tableFromItems(LoginStatusFields, printables, isMachineReadable(output))
return t.print(output)
}
if len(statuses) == 0 {
fmt.Println("No logins configured.")
return nil
}
for i, status := range statuses {
if i > 0 {
fmt.Println()
}
printLoginStatusReport(status)
}
return nil
}
func printLoginStatusReport(status LoginStatus) {
name := status.Name
if status.Default {
name += " (default)"
}
fmt.Println(name)
if status.Valid {
line := " ✔ Logged in to " + status.URL
if status.User != "" {
line += " as " + status.User
}
fmt.Println(line)
tokenLine := " ✔ Token is valid"
if status.AuthMethod != "" {
tokenLine += " (" + status.AuthMethod
if !status.TokenExpiry.IsZero() {
tokenLine += ", " + formatTokenExpiry(status.TokenExpiry)
}
tokenLine += ")"
}
fmt.Println(tokenLine)
} else {
message := status.Error
if message == "" {
message = "Login failed"
}
fmt.Println(" ✗ " + message)
}
if status.Helper {
fmt.Println(" ✔ Git credential helper configured")
} else {
fmt.Println(" ✗ Git credential helper not configured")
}
}
func formatExpiryDuration(t time.Time) string {
d := time.Until(t)
if d < 0 {
return "expired"
}
if d < time.Minute {
return "in less than a minute"
}
return "in " + strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
}
func formatTokenExpiry(t time.Time) string {
if t.Before(time.Now()) {
return "expired"
}
return "expires " + formatExpiryDuration(t)
}
// FormatField implements the printable interface for LoginStatus.
func (s LoginStatus) FormatField(field string, machineReadable bool) string {
switch field {
case "name":
return s.Name
case "url":
return s.URL
case "user":
return s.User
case "valid":
return formatBoolean(s.Valid, !machineReadable)
case "auth_method":
return s.AuthMethod
case "token_expiry":
if s.TokenExpiry.IsZero() {
return ""
}
if machineReadable {
return FormatTime(s.TokenExpiry, true)
}
return formatExpiryDuration(s.TokenExpiry)
case "helper":
return formatBoolean(s.Helper, !machineReadable)
case "default":
return formatBoolean(s.Default, !machineReadable)
}
return ""
}
-38
View File
@@ -1,38 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLoginStatusFormatField(t *testing.T) {
status := LoginStatus{
Name: "gitea",
URL: "https://gitea.com",
User: "alice",
Valid: true,
AuthMethod: "oauth",
TokenExpiry: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC),
Helper: true,
Default: true,
}
assert.Equal(t, "gitea", status.FormatField("name", false))
assert.Equal(t, "https://gitea.com", status.FormatField("url", false))
assert.Equal(t, "alice", status.FormatField("user", false))
assert.Equal(t, "true", status.FormatField("valid", true))
assert.Equal(t, "✔", status.FormatField("valid", false))
assert.Equal(t, "oauth", status.FormatField("auth_method", true))
assert.Equal(t, "2026-08-27T12:00:00Z", status.FormatField("token_expiry", true))
assert.Equal(t, "true", status.FormatField("helper", true))
assert.Equal(t, "✔", status.FormatField("default", false))
}
func TestFormatExpiryDurationExpired(t *testing.T) {
assert.Equal(t, "expired", formatExpiryDuration(time.Now().Add(-time.Hour)))
}
-62
View File
@@ -1,62 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"os"
"charm.land/glamour/v2"
"golang.org/x/term"
)
// outputMarkdown prints markdown to stdout, formatted for terminals.
// If the input could not be parsed, it is printed unformatted, the error
// is returned anyway.
func outputMarkdown(markdown string, baseURL string) error {
var renderer *glamour.TermRenderer
var err error
if IsInteractive() {
renderer, err = glamour.NewTermRenderer(
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
} else {
renderer, err = glamour.NewTermRenderer(
glamour.WithStandardStyle("notty"),
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
}
if err != nil {
fmt.Print(markdown)
return err
}
out, err := renderer.Render(markdown)
if err != nil {
fmt.Print(markdown)
return err
}
fmt.Print(out)
return nil
}
// stolen from https://github.com/charmbracelet/glow/blob/e9d728c/main.go#L152-L165
func getWordWrap() int {
fd := int(os.Stdout.Fd())
width := 80
if term.IsTerminal(fd) {
if w, _, err := term.GetSize(fd); err == nil {
width = w
}
}
if width > 120 {
width = 120
}
return width
}
-87
View File
@@ -1,87 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// MilestoneDetails print an milestone formatted to stdout
func MilestoneDetails(milestone *gitea.Milestone) {
fmt.Printf("%s\n",
milestone.Title,
)
if len(milestone.Description) != 0 {
outputMarkdown(milestone.Description, "")
}
if milestone.Deadline != nil && !milestone.Deadline.IsZero() {
fmt.Printf("\nDeadline: %s\n", FormatTime(*milestone.Deadline, false))
}
}
// MilestonesList prints a listing of milestones
func MilestonesList(news []*gitea.Milestone, output string, fields []string) error {
printables := make([]printable, len(news))
for i, x := range news {
printables[i] = &printableMilestone{x}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
t.sort(0, true)
return t.print(output)
}
// MilestoneFields are all available fields to print with MilestonesList
var MilestoneFields = []string{
"title",
"state",
"items_open",
"items_closed",
"items",
"duedate",
"description",
"created",
"updated",
"closed",
"id",
}
type printableMilestone struct {
*gitea.Milestone
}
func (m printableMilestone) FormatField(field string, machineReadable bool) string {
switch field {
case "title":
return m.Title
case "state":
return string(m.State)
case "items_open":
return fmt.Sprintf("%d", m.OpenIssues)
case "items_closed":
return fmt.Sprintf("%d", m.ClosedIssues)
case "items":
return fmt.Sprintf("%d/%d", m.OpenIssues, m.ClosedIssues)
case "duedate":
if m.Deadline != nil && !m.Deadline.IsZero() {
return FormatTime(*m.Deadline, machineReadable)
}
case "id":
return fmt.Sprintf("%d", m.ID)
case "description":
return m.Description
case "created":
return FormatTime(m.Created, machineReadable)
case "updated":
if m.Updated != nil {
return FormatTime(*m.Updated, machineReadable)
}
case "closed":
if m.Closed != nil {
return FormatTime(*m.Closed, machineReadable)
}
}
return ""
}
-82
View File
@@ -1,82 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
)
// NotificationsList prints a listing of notification threads
func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) error {
printables := make([]printable, len(news))
for i, x := range news {
printables[i] = &printableNotification{x}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
// NotificationFields are all available fields to print with NotificationsList
var NotificationFields = []string{
"id",
"status",
"updated",
// these are about the notification subject
"index",
"type",
"state",
"title",
"repository",
}
type printableNotification struct {
*gitea.NotificationThread
}
func (n printableNotification) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", n.ID)
case "status":
status := "read"
if n.Pinned {
status = "pinned"
} else if n.Unread {
status = "unread"
}
return status
case "updated":
return FormatTime(n.UpdatedAt, machineReadable)
case "index":
var index string
if n.Subject.Type == "Issue" || n.Subject.Type == "Pull" {
index = n.Subject.URL
urlParts := strings.Split(n.Subject.URL, "/")
if len(urlParts) != 0 {
index = urlParts[len(urlParts)-1]
}
}
return index
case "type":
return string(n.Subject.Type)
case "state":
return string(n.Subject.State)
case "title":
return n.Subject.Title
case "repo", "repository":
return n.Repository.FullName
}
return ""
}
-50
View File
@@ -1,50 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// OrganizationDetails prints details of an org with formatting
func OrganizationDetails(org *gitea.Organization) {
_ = outputMarkdown(fmt.Sprintf(
"# %s\n%s\n\n- Visibility: %s\n- Location: %s\n- Website: %s\n",
org.UserName,
org.Description,
org.Visibility,
org.Location,
org.Website,
), "")
}
// OrganizationsList prints a listing of the organizations
func OrganizationsList(organizations []*gitea.Organization, output string) error {
if len(organizations) == 0 {
fmt.Println("No organizations found")
return nil
}
t := tableWithHeader(
"Name",
"FullName",
"Website",
"Location",
"Description",
)
for _, org := range organizations {
t.addRow(
org.UserName,
org.FullName,
org.Website,
org.Location,
org.Description,
)
}
return t.print(output)
}
-290
View File
@@ -1,290 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"gitea.dev/sdk"
)
var ciStatusSymbols = map[gitea.StatusState]string{
gitea.StatusSuccess: "✓ ",
gitea.StatusPending: "⏳ ",
gitea.StatusWarning: "⚠ ",
gitea.StatusError: "✘ ",
gitea.StatusFailure: "❌ ",
}
// PullDetails print an pull rendered to stdout
func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *gitea.CombinedStatus) {
base := pr.Base.Name
head := formatPRHead(pr)
state := formatPRState(pr)
out := fmt.Sprintf(
"# #%d %s (%s)\n@%s created %s\t**%s** <- **%s**\n\n%s\n\n",
pr.Index,
pr.Title,
state,
pr.Poster.UserName,
FormatTime(*pr.Created, false),
base,
head,
pr.Body,
)
if ciStatus != nil || len(reviews) != 0 || pr.State == gitea.StateOpen {
out += "---\n"
}
out += formatReviews(pr, reviews)
if ciStatus != nil && len(ciStatus.Statuses) != 0 {
out += "- CI:\n"
for _, s := range ciStatus.Statuses {
symbol := ciStatusSymbols[s.State]
if s.TargetURL != "" {
out += fmt.Sprintf(" - %s[**%s**](%s)", symbol, s.Context, s.TargetURL)
} else {
out += fmt.Sprintf(" - %s**%s**", symbol, s.Context)
}
if s.Description != "" {
out += fmt.Sprintf(": %s", s.Description)
}
out += "\n"
}
}
if pr.State == gitea.StateOpen {
switch {
case pr.Mergeable:
out += "- No Conflicts\n"
case pr.Draft:
out += "- Draft (not mergeable until marked ready)\n"
default:
out += "- **Conflicting files**\n"
}
}
if pr.AllowMaintainerEdit {
out += "- Maintainers are allowed to edit\n"
}
if pr.HTMLURL != "" {
out = strings.TrimRight(out, "\n") + "\n\n" + pr.HTMLURL + "\n"
}
outputMarkdown(out, getRepoURL(pr.HTMLURL))
}
func formatPRHead(pr *gitea.PullRequest) string {
head := pr.Head.Name
if pr.Head.RepoID != pr.Base.RepoID {
if pr.Head.Repository != nil {
head = pr.Head.Repository.Owner.UserName + ":" + head
} else {
head = "delete:" + head
}
}
return head
}
func formatPRState(pr *gitea.PullRequest) string {
if pr.Merged != nil {
return "merged"
}
return string(pr.State)
}
func formatCIStatus(ci *gitea.CombinedStatus, machineReadable bool) string {
if ci == nil || len(ci.Statuses) == 0 {
return ""
}
if machineReadable {
return string(ci.State)
}
items := make([]string, 0, len(ci.Statuses))
for _, s := range ci.Statuses {
items = append(items, fmt.Sprintf("%s%s", ciStatusSymbols[s.State], s.Context))
}
return strings.Join(items, ", ")
}
func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
result := ""
if len(reviews) == 0 {
return result
}
// deduplicate reviews by user (via review time & userID),
reviewByUserOrTeam := make(map[string]*gitea.PullReview)
for _, review := range reviews {
switch review.State {
case gitea.ReviewStateApproved,
gitea.ReviewStateRequestChanges,
gitea.ReviewStateRequestReview:
if review.Reviewer != nil {
if r, ok := reviewByUserOrTeam[fmt.Sprintf("user_%d", review.Reviewer.ID)]; !ok || review.Submitted.After(r.Submitted) {
reviewByUserOrTeam[fmt.Sprintf("user_%d", review.Reviewer.ID)] = review
}
} else if review.ReviewerTeam != nil {
if r, ok := reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)]; !ok || review.Submitted.After(r.Submitted) {
reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)] = review
}
}
}
}
// group reviews by type
reviewByState := make(map[gitea.ReviewStateType][]string)
for _, r := range reviewByUserOrTeam {
if r.Reviewer != nil {
reviewByState[r.State] = append(reviewByState[r.State],
r.Reviewer.UserName,
)
} else if r.ReviewerTeam != nil {
// only pulls to orgs can have team reviews
org := pr.Base.Repository.Owner
reviewByState[r.State] = append(reviewByState[r.State],
fmt.Sprintf("%s/%s", org.UserName, r.ReviewerTeam.Name),
)
}
}
// stringify
for state, user := range reviewByState {
result += fmt.Sprintf("- %s by @%s\n", state, strings.Join(user, ", @"))
}
return result
}
// PullsList prints a listing of pulls
func PullsList(prs []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
return printPulls(prs, output, fields, ciStatuses)
}
// PullFields are all available fields to print with PullsList()
var PullFields = []string{
"index",
"state",
"author",
"author-id",
"url",
"title",
"body",
"mergeable",
"base",
"base-commit",
"head",
"diff",
"patch",
"created",
"updated",
"deadline",
"assignees",
"milestone",
"labels",
"comments",
"ci",
}
func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
labelMap := map[int64]string{}
printables := make([]printable, len(pulls))
machineReadable := isMachineReadable(output)
for i, x := range pulls {
// pre-serialize labels for performance
for _, label := range x.Labels {
if _, ok := labelMap[label.ID]; !ok {
labelMap[label.ID] = formatLabel(label, !machineReadable, "")
}
}
// store items with printable interface
printables[i] = &printablePull{x, &labelMap, &ciStatuses}
}
t := tableFromItems(fields, printables, machineReadable)
return t.print(output)
}
type printablePull struct {
*gitea.PullRequest
formattedLabels *map[int64]string
ciStatuses *map[int64]*gitea.CombinedStatus
}
func (x printablePull) FormatField(field string, machineReadable bool) string {
switch field {
case "index":
return fmt.Sprintf("%d", x.Index)
case "state":
return formatPRState(x.PullRequest)
case "author":
return formatUserName(x.Poster)
case "author-id":
return x.Poster.UserName
case "url":
return x.HTMLURL
case "title":
return x.Title
case "body":
return x.Body
case "created":
return FormatTime(*x.Created, machineReadable)
case "updated":
return FormatTime(*x.Updated, machineReadable)
case "deadline":
if x.Deadline == nil {
return ""
}
return FormatTime(*x.Deadline, machineReadable)
case "milestone":
if x.Milestone != nil {
return x.Milestone.Title
}
return ""
case "labels":
labels := make([]string, len(x.Labels))
for i, l := range x.Labels {
labels[i] = (*x.formattedLabels)[l.ID]
}
return strings.Join(labels, " ")
case "assignees":
assignees := make([]string, len(x.Assignees))
for i, a := range x.Assignees {
assignees[i] = formatUserName(a)
}
return strings.Join(assignees, " ")
case "comments":
return fmt.Sprintf("%d", x.Comments)
case "mergeable":
isMergeable := x.Mergeable && x.State == gitea.StateOpen
return formatBoolean(isMergeable, !machineReadable)
case "base":
return x.Base.Ref
case "base-commit":
return x.MergeBase
case "head":
return formatPRHead(x.PullRequest)
case "diff":
return x.DiffURL
case "patch":
return x.PatchURL
case "ci":
if x.ciStatuses != nil {
if ci, ok := (*x.ciStatuses)[x.Index]; ok {
return formatCIStatus(ci, machineReadable)
}
}
return ""
}
return ""
}
-73
View File
@@ -1,73 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// PullReviewCommentFields are all available fields to print with PullReviewCommentsList()
var PullReviewCommentFields = []string{
"id",
"body",
"reviewer",
"path",
"line",
"resolver",
"created",
"updated",
"url",
}
// PullReviewCommentsList prints a listing of pull review comments
func PullReviewCommentsList(comments []*gitea.PullReviewComment, output string, fields []string) error {
printables := make([]printable, len(comments))
for i, c := range comments {
printables[i] = &printablePullReviewComment{c}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
type printablePullReviewComment struct {
*gitea.PullReviewComment
}
func (x printablePullReviewComment) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", x.ID)
case "body":
return x.Body
case "reviewer":
if x.Reviewer != nil {
return formatUserName(x.Reviewer)
}
return ""
case "path":
return x.Path
case "line":
if x.LineNum != 0 {
return fmt.Sprintf("%d", x.LineNum)
}
if x.OldLineNum != 0 {
return fmt.Sprintf("%d", x.OldLineNum)
}
return ""
case "resolver":
if x.Resolver != nil {
return formatUserName(x.Resolver)
}
return ""
case "created":
return FormatTime(x.Created, machineReadable)
case "updated":
return FormatTime(x.Updated, machineReadable)
case "url":
return x.HTMLURL
}
return ""
}
-200
View File
@@ -1,200 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"slices"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestPR(index int64, title string) *gitea.PullRequest {
now := time.Now()
return &gitea.PullRequest{
Index: index,
Title: title,
State: gitea.StateOpen,
Poster: &gitea.User{UserName: "testuser"},
Head: &gitea.PRBranchInfo{Ref: "branch", Name: "branch"},
Base: &gitea.PRBranchInfo{Ref: "main", Name: "main"},
Created: &now,
Updated: &now,
}
}
func TestFormatCIStatusNil(t *testing.T) {
assert.Equal(t, "", formatCIStatus(nil, false))
assert.Equal(t, "", formatCIStatus(nil, true))
}
func TestFormatCIStatusEmpty(t *testing.T) {
ci := &gitea.CombinedStatus{Statuses: []*gitea.Status{}}
assert.Equal(t, "", formatCIStatus(ci, false))
assert.Equal(t, "", formatCIStatus(ci, true))
}
func TestFormatCIStatusMachineReadable(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
},
}
assert.Equal(t, "success", formatCIStatus(ci, true))
ci.State = gitea.StatusPending
ci.Statuses = []*gitea.Status{
{State: gitea.StatusPending, Context: "build"},
}
assert.Equal(t, "pending", formatCIStatus(ci, true))
}
func TestFormatCIStatusSingle(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
},
}
assert.Equal(t, "✓ lint", formatCIStatus(ci, false))
}
func TestFormatCIStatusMultiple(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusFailure,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
{State: gitea.StatusPending, Context: "build"},
{State: gitea.StatusFailure, Context: "test"},
},
}
assert.Equal(t, "✓ lint, ⏳ build, ❌ test", formatCIStatus(ci, false))
}
func TestFormatCIStatusAllStates(t *testing.T) {
tests := []struct {
state gitea.StatusState
context string
expected string
}{
{gitea.StatusSuccess, "s", "✓ s"},
{gitea.StatusPending, "p", "⏳ p"},
{gitea.StatusWarning, "w", "⚠ w"},
{gitea.StatusError, "e", "✘ e"},
{gitea.StatusFailure, "f", "❌ f"},
}
for _, tt := range tests {
ci := &gitea.CombinedStatus{
State: tt.state,
Statuses: []*gitea.Status{{State: tt.state, Context: tt.context}},
}
assert.Equal(t, tt.expected, formatCIStatus(ci, false), "state: %s", tt.state)
}
}
func TestPullDetailsIncludesHTMLURL(t *testing.T) {
pr := newTestPR(8, "Crm wip")
pr.HTMLURL = "https://gitea.example.com/owner/repo/pulls/8"
out := captureStdout(t, func() {
PullDetails(pr, nil, nil)
})
assert.Contains(t, out, pr.HTMLURL)
}
func TestPullsListWithCIField(t *testing.T) {
prs := []*gitea.PullRequest{
newTestPR(1, "feat: add feature"),
newTestPR(2, "fix: bug fix"),
}
ciStatuses := map[int64]*gitea.CombinedStatus{
1: {
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "ci/build"},
},
},
2: {
State: gitea.StatusFailure,
Statuses: []*gitea.Status{
{State: gitea.StatusFailure, Context: "ci/test"},
},
},
}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
&printablePull{prs[1], &map[int64]string{}, &ciStatuses},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 2)
assert.Equal(t, "1", result[0]["index"])
assert.Equal(t, "success", result[0]["ci"])
assert.Equal(t, "2", result[1]["index"])
assert.Equal(t, "failure", result[1]["ci"])
}
func TestPullsListCIFieldEmpty(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "no ci")}
ciStatuses := map[int64]*gitea.CombinedStatus{}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
assert.Equal(t, "", result[0]["ci"])
}
func TestPullsListNilCIStatusesWithCIField(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "nil ci")}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, nil},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
assert.Equal(t, "", result[0]["ci"])
}
func TestPullsListNoCIFieldNoPanic(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "test")}
require.NoError(t, PullsList(prs, "", []string{"index", "title"}, nil))
}
func TestPullFieldsContainsCI(t *testing.T) {
assert.True(t, slices.Contains(PullFields, "ci"), "PullFields should contain 'ci'")
}
-37
View File
@@ -1,37 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"gitea.dev/sdk"
)
// ReleasesList prints a listing of releases
func ReleasesList(releases []*gitea.Release, output string) error {
t := tableWithHeader(
"Tag-Name",
"Title",
"Published At",
"Status",
"Tar/Zip URL",
)
for _, release := range releases {
status := "released"
if release.IsDraft {
status = "draft"
} else if release.IsPrerelease {
status = "prerelease"
}
t.addRow(
release.TagName,
release.Title,
FormatTime(release.PublishedAt, isMachineReadable(output)),
status,
release.TarURL+"\n"+release.ZipURL,
)
}
return t.print(output)
}
-141
View File
@@ -1,141 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
"gitea.dev/sdk"
)
// ReposList prints a listing of the repos
func ReposList(repos []*gitea.Repository, output string, fields []string) error {
printables := make([]printable, len(repos))
for i, r := range repos {
printables[i] = &printableRepo{r}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
// RepoDetails print an repo formatted to stdout
func RepoDetails(repo *gitea.Repository, topics []string) {
title := "# " + repo.FullName
if repo.Mirror {
title += " (mirror)"
}
if repo.Fork {
title += " (fork)"
}
if repo.Archived {
title += " (archived)"
}
if repo.Empty {
title += " (empty)"
}
title += "\n"
var desc string
if len(repo.Description) != 0 {
desc = fmt.Sprintf("*%s*\n\n", repo.Description)
}
stats := fmt.Sprintf(
"Issues: %d, Stars: %d, Forks: %d, Size: %s\n",
repo.OpenIssues,
repo.Stars,
repo.Forks,
formatSize(int64(repo.Size)),
)
// NOTE: for mirrors, this is the time the mirror was last fetched..
updated := fmt.Sprintf(
"Updated: %s (%s ago)\n",
repo.Updated.Format("2006-01-02 15:04"),
time.Since(repo.Updated).Truncate(time.Minute),
)
urls := fmt.Sprintf(
"- Browse:\t%s\n- Clone:\t%s\n",
repo.HTMLURL,
repo.SSHURL,
)
if len(repo.Website) != 0 {
urls += fmt.Sprintf("- Web:\t%s\n", repo.Website)
}
perm := fmt.Sprintf(
"- Permission:\t%s\n",
formatPermission(repo.Permissions),
)
var tops string
if len(topics) != 0 {
tops = fmt.Sprintf("- Topics:\t%s\n", strings.Join(topics, ", "))
}
outputMarkdown(fmt.Sprintf(
"%s%s\n%s\n%s%s%s%s",
title,
desc,
stats,
updated,
urls,
perm,
tops,
), repo.HTMLURL)
}
// RepoFields are the available fields to print with ReposList()
var RepoFields = []string{
"description",
"forks",
"id",
"name",
"owner",
"stars",
"ssh",
"updated",
"url",
"permission",
"type",
}
type printableRepo struct{ *gitea.Repository }
func (x printableRepo) FormatField(field string, machineReadable bool) string {
switch field {
case "description":
return x.Description
case "forks":
return fmt.Sprintf("%d", x.Forks)
case "id":
return fmt.Sprintf("%d", x.ID)
case "name":
return x.Name
case "owner":
return x.Owner.UserName
case "stars":
return fmt.Sprintf("%d", x.Stars)
case "ssh":
return x.SSHURL
case "updated":
return FormatTime(x.Updated, machineReadable)
case "url":
return x.HTMLURL
case "permission":
return formatPermission(x.Permissions)
case "type":
if x.Fork {
return "fork"
}
if x.Mirror {
return "mirror"
}
return "source"
}
return ""
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/require"
)
func TestReposListUsesNumericIDField(t *testing.T) {
repos := []*gitea.Repository{{
ID: 123,
Name: "tea",
Owner: &gitea.User{
UserName: "gitea",
},
}}
buf := &bytes.Buffer{}
tbl := tableFromItems([]string{"id", "name"}, []printable{&printableRepo{repos[0]}}, true)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
require.Equal(t, "123", result[0]["id"])
require.Equal(t, "tea", result[0]["name"])
}
-44
View File
@@ -1,44 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// SSHKeysList prints a table of SSH public keys
func SSHKeysList(keys []*gitea.PublicKey, output string) error {
if len(keys) == 0 {
fmt.Printf("No SSH keys found\n")
return nil
}
t := tableWithHeader(
"ID",
"Title",
"Fingerprint",
"KeyType",
"ReadOnly",
"Created",
)
for _, k := range keys {
readOnly := "false"
if k.ReadOnly {
readOnly = "true"
}
t.addRow(
fmt.Sprintf("%d", k.ID),
k.Title,
k.Fingerprint,
k.KeyType,
readOnly,
FormatTime(k.Created, false),
)
}
return t.print(output)
}
-235
View File
@@ -1,235 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"sort"
"strconv"
"strings"
"github.com/olekukonko/tablewriter"
"gopkg.in/yaml.v3"
)
// table provides infrastructure to easily print (sorted) lists in different formats
type table struct {
headers []string
values [][]string
sortDesc bool // used internally by sortable interface
sortColumn uint // ↑
}
// printable can be implemented for structs to put fields dynamically into a table
type printable interface {
FormatField(field string, machineReadable bool) string
}
// high level api to print a table of items with dynamic fields
func tableFromItems(fields []string, values []printable, machineReadable bool) table {
t := table{headers: fields}
for _, v := range values {
row := make([]string, len(fields))
for i, f := range fields {
row[i] = v.FormatField(f, machineReadable)
}
t.addRowSlice(row)
}
return t
}
func tableWithHeader(header ...string) table {
return table{headers: header}
}
// it's the callers responsibility to ensure row length is equal to header length!
func (t *table) addRow(row ...string) {
t.addRowSlice(row)
}
// it's the callers responsibility to ensure row length is equal to header length!
func (t *table) addRowSlice(row []string) {
t.values = append(t.values, row)
}
func (t *table) sort(column uint, desc bool) {
t.sortColumn = column
t.sortDesc = desc
sort.Stable(t) // stable to allow multiple calls to sort
}
// sortable interface
func (t table) Len() int { return len(t.values) }
func (t table) Swap(i, j int) { t.values[i], t.values[j] = t.values[j], t.values[i] }
func (t table) Less(i, j int) bool {
if t.sortDesc {
i, j = j, i
}
return t.values[i][t.sortColumn] < t.values[j][t.sortColumn]
}
func (t *table) print(output string) error {
return t.fprint(os.Stdout, output)
}
func (t *table) fprint(f io.Writer, output string) error {
switch output {
case "", "table":
return outputTable(f, t.headers, t.values)
case "csv":
return outputDsv(f, t.headers, t.values, ',')
case "simple":
return outputSimple(f, t.headers, t.values)
case "tsv":
return outputDsv(f, t.headers, t.values, '\t')
case "yml", "yaml":
return outputYaml(f, t.headers, t.values)
case "json":
return outputJSON(f, t.headers, t.values)
default:
return fmt.Errorf("unknown output type %q, available types are: csv, simple, table, tsv, yaml, json", output)
}
}
// outputTable prints structured data as table
func outputTable(f io.Writer, headers []string, values [][]string) error {
table := tablewriter.NewWriter(f)
if len(headers) > 0 {
table.Header(headers)
}
for _, value := range values {
if err := table.Append(value); err != nil {
return err
}
}
return table.Render()
}
// outputSimple prints structured data as space delimited value
func outputSimple(f io.Writer, headers []string, values [][]string) error {
for _, value := range values {
if _, err := fmt.Fprintln(f, strings.Join(value, " ")); err != nil {
return err
}
}
return nil
}
// outputDsv prints structured data as delimiter separated value format.
func outputDsv(f io.Writer, headers []string, values [][]string, delimiter rune) error {
writer := csv.NewWriter(f)
writer.Comma = delimiter
if err := writer.Write(headers); err != nil {
return err
}
for _, value := range values {
if err := writer.Write(value); err != nil {
return err
}
}
writer.Flush()
return writer.Error()
}
// outputYaml prints structured data as yaml
func outputYaml(f io.Writer, headers []string, values [][]string) error {
root := &yaml.Node{Kind: yaml.SequenceNode}
for _, value := range values {
row := &yaml.Node{Kind: yaml.MappingNode}
for j, val := range value {
row.Content = append(row.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: headers[j],
})
valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val}
if _, err := strconv.ParseInt(val, 10, 64); err == nil {
valueNode.Tag = "!!int"
} else {
valueNode.Tag = "!!str"
}
row.Content = append(row.Content, valueNode)
}
root.Content = append(root.Content, row)
}
encoder := yaml.NewEncoder(f)
if err := encoder.Encode(root); err != nil {
_ = encoder.Close()
return err
}
return encoder.Close()
}
var (
matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
)
func toSnakeCase(str string) string {
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
return strings.ToLower(snake)
}
// orderedRow preserves header insertion order when marshaled to JSON.
type orderedRow struct {
keys []string
values map[string]string
}
func (o orderedRow) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range o.keys {
if i > 0 {
buf.WriteByte(',')
}
key, err := json.Marshal(k)
if err != nil {
return nil, err
}
val, err := json.Marshal(o.values[k])
if err != nil {
return nil, err
}
buf.Write(key)
buf.WriteByte(':')
buf.Write(val)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// outputJSON prints structured data as json, preserving header field order.
func outputJSON(f io.Writer, headers []string, values [][]string) error {
snakeHeaders := make([]string, len(headers))
for i, h := range headers {
snakeHeaders[i] = toSnakeCase(h)
}
rows := make([]orderedRow, 0, len(values))
for _, value := range values {
row := orderedRow{keys: snakeHeaders, values: make(map[string]string, len(headers))}
for j, val := range value {
row.values[snakeHeaders[j]] = val
}
rows = append(rows, row)
}
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
return encoder.Encode(rows)
}
func isMachineReadable(outputFormat string) bool {
switch outputFormat {
case "yml", "yaml", "csv", "tsv", "json":
return true
}
return false
}
-116
View File
@@ -1,116 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/csv"
"encoding/json"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestToSnakeCase(t *testing.T) {
assert.EqualValues(t, "some_test_var_at2d", toSnakeCase("SomeTestVarAt2d"))
}
func TestPrint(t *testing.T) {
tData := &table{
headers: []string{"A", "B"},
values: [][]string{
{"new a", "some bbbb"},
{"AAAAA", "b2"},
{"\"abc", "\"def"},
{"'abc", "de'f"},
{"\\abc", "'def\\"},
},
}
buf := &bytes.Buffer{}
require.NoError(t, tData.fprint(buf, "json"))
result := []struct {
A string
B string
}{}
assert.NoError(t, json.NewDecoder(buf).Decode(&result))
if assert.Len(t, result, 5) {
assert.EqualValues(t, "new a", result[0].A)
assert.EqualValues(t, "some bbbb", result[0].B)
assert.EqualValues(t, "AAAAA", result[1].A)
assert.EqualValues(t, "b2", result[1].B)
assert.EqualValues(t, "\"abc", result[2].A)
assert.EqualValues(t, "\"def", result[2].B)
assert.EqualValues(t, "'abc", result[3].A)
assert.EqualValues(t, "de'f", result[3].B)
assert.EqualValues(t, "\\abc", result[4].A)
assert.EqualValues(t, "'def\\", result[4].B)
}
buf.Reset()
require.NoError(t, tData.fprint(buf, "yaml"))
var yamlResult []map[string]string
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &yamlResult))
assert.Equal(t, []map[string]string{
{"A": "new a", "B": "some bbbb"},
{"A": "AAAAA", "B": "b2"},
{"A": "\"abc", "B": "\"def"},
{"A": "'abc", "B": "de'f"},
{"A": "\\abc", "B": "'def\\"},
}, yamlResult)
}
func TestPrintCSVUsesEscaping(t *testing.T) {
tData := &table{
headers: []string{"A", "B"},
values: [][]string{
{"hello,world", `quote "here"`},
{"multi\nline", "plain"},
},
}
buf := &bytes.Buffer{}
require.NoError(t, tData.fprint(buf, "csv"))
reader := csv.NewReader(bytes.NewReader(buf.Bytes()))
records, err := reader.ReadAll()
require.NoError(t, err)
assert.Equal(t, [][]string{
{"A", "B"},
{"hello,world", `quote "here"`},
{"multi\nline", "plain"},
}, records)
}
func TestPrintJSONPreservesFieldOrder(t *testing.T) {
tData := &table{
headers: []string{"Zebra", "Apple", "Mango"},
values: [][]string{{"z", "a", "m"}},
}
buf := &bytes.Buffer{}
require.NoError(t, tData.fprint(buf, "json"))
// Keys must appear in header order (Zebra, Apple, Mango), not sorted alphabetically
raw := buf.String()
zebraIdx := bytes.Index([]byte(raw), []byte(`"zebra"`))
appleIdx := bytes.Index([]byte(raw), []byte(`"apple"`))
mangoIdx := bytes.Index([]byte(raw), []byte(`"mango"`))
assert.Greater(t, appleIdx, zebraIdx, "apple should appear after zebra")
assert.Greater(t, mangoIdx, appleIdx, "mango should appear after apple")
}
func TestPrintUnknownOutputReturnsError(t *testing.T) {
tData := &table{headers: []string{"A"}, values: [][]string{{"value"}}}
err := tData.fprint(io.Discard, "unknown")
require.ErrorContains(t, err, `unknown output type "unknown"`)
}
-63
View File
@@ -1,63 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// TrackedTimesList print list of tracked times to stdout
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) error {
printables := make([]printable, len(times))
var totalDuration int64
for i, t := range times {
totalDuration += t.Time
printables[i] = &printableTrackedTime{t, outputType}
}
t := tableFromItems(fields, printables, isMachineReadable(outputType))
if printTotal {
total := make([]string, len(fields))
total[0] = "TOTAL"
total[len(fields)-1] = formatDuration(totalDuration, outputType)
t.addRowSlice(total)
}
return t.print(outputType)
}
// TrackedTimeFields contains all available fields for printing of tracked times.
var TrackedTimeFields = []string{
"id",
"created",
"repo",
"issue",
"user",
"duration",
}
type printableTrackedTime struct {
*gitea.TrackedTime
outputFormat string
}
func (t printableTrackedTime) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", t.ID)
case "created":
return FormatTime(t.Created, machineReadable)
case "repo":
return t.Issue.Repository.FullName
case "issue":
return fmt.Sprintf("#%d", t.Issue.Index)
case "user":
return t.UserName
case "duration":
return formatDuration(t.Time, t.outputFormat)
}
return ""
}
-134
View File
@@ -1,134 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"gitea.dev/sdk"
)
// UserDetails print a formatted user to stdout
func UserDetails(user *gitea.User) {
title := "# " + user.UserName
if user.IsAdmin {
title += " (admin)"
}
if !user.IsActive {
title += " (disabled)"
}
if user.Restricted {
title += " (restricted)"
}
if user.ProhibitLogin {
title += " (login prohibited)"
}
title += "\n"
var desc string
if len(user.Description) != 0 {
desc = fmt.Sprintf("*%s*\n\n", user.Description)
}
var website string
if len(user.Website) != 0 {
website = fmt.Sprintf("%s\n\n", user.Website)
}
stats := fmt.Sprintf(
"Follower Count: %d, Following Count: %d, Starred Repos: %d\n",
user.FollowerCount,
user.FollowingCount,
user.StarredRepoCount,
)
outputMarkdown(fmt.Sprintf(
"%s%s\n%s\n%s",
title,
desc,
website,
stats,
), "")
}
// UserList prints a listing of the users
func UserList(user []*gitea.User, output string, fields []string) error {
printables := make([]printable, len(user))
for i, u := range user {
printables[i] = &printableUser{u}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
// UserFields are the available fields to print with UserList()
var UserFields = []string{
"id",
"login",
"full_name",
"email",
"avatar_url",
"language",
"is_admin",
"restricted",
"prohibit_login",
"location",
"website",
"description",
"visibility",
"activated",
"lastlogin_at",
"created_at",
}
type printableUser struct{ *gitea.User }
func (x printableUser) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", x.ID)
case "login":
if x.IsAdmin {
return fmt.Sprintf("%s (admin)", x.UserName)
}
if !x.IsActive {
return fmt.Sprintf("%s (disabled)", x.UserName)
}
if x.Restricted {
return fmt.Sprintf("%s (restricted)", x.UserName)
}
if x.ProhibitLogin {
return fmt.Sprintf("%s (login prohibited)", x.UserName)
}
return x.UserName
case "full_name":
return x.FullName
case "email":
return x.Email
case "avatar_url":
return x.AvatarURL
case "language":
return x.Language
case "is_admin":
return formatBoolean(x.IsAdmin, !machineReadable)
case "restricted":
return formatBoolean(x.Restricted, !machineReadable)
case "prohibit_login":
return formatBoolean(x.ProhibitLogin, !machineReadable)
case "activated":
return formatBoolean(x.IsActive, !machineReadable)
case "location":
return x.Location
case "website":
return x.Website
case "description":
return x.Description
case "visibility":
return string(x.Visibility)
case "created_at":
return FormatTime(x.Created, machineReadable)
case "lastlogin_at":
return FormatTime(x.LastLogin, machineReadable)
}
return ""
}
-90
View File
@@ -1,90 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strconv"
"strings"
"gitea.dev/sdk"
)
// WebhooksList prints a listing of webhooks
func WebhooksList(hooks []*gitea.Hook, output string) error {
t := tableWithHeader(
"ID",
"Type",
"URL",
"Events",
"Active",
"Updated",
)
for _, hook := range hooks {
var url string
if hook.Config != nil {
url = hook.Config["url"]
}
events := strings.Join(hook.Events, ",")
if len(events) > 40 {
events = events[:37] + "..."
}
active := "✓"
if !hook.Active {
active = "✗"
}
t.addRow(
strconv.FormatInt(hook.ID, 10),
string(hook.Type),
url,
events,
active,
FormatTime(hook.Updated, false),
)
}
return t.print(output)
}
// WebhookDetails prints detailed information about a webhook
func WebhookDetails(hook *gitea.Hook) {
fmt.Printf("# Webhook %d\n\n", hook.ID)
fmt.Printf("- **Type**: %s\n", hook.Type)
fmt.Printf("- **Active**: %t\n", hook.Active)
fmt.Printf("- **Created**: %s\n", FormatTime(hook.Created, false))
fmt.Printf("- **Updated**: %s\n", FormatTime(hook.Updated, false))
if hook.Config != nil {
fmt.Printf("- **URL**: %s\n", hook.Config["url"])
if contentType, ok := hook.Config["content_type"]; ok {
fmt.Printf("- **Content Type**: %s\n", contentType)
}
if method, ok := hook.Config["http_method"]; ok {
fmt.Printf("- **HTTP Method**: %s\n", method)
}
branchFilter := hook.BranchFilter
if branchFilter == "" {
branchFilter = hook.Config["branch_filter"]
}
if branchFilter != "" {
fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
}
if _, hasSecret := hook.Config["secret"]; hasSecret {
fmt.Printf("- **Secret**: (configured)\n")
}
hasAuth := hook.AuthorizationHeader != ""
if !hasAuth {
_, hasAuth = hook.Config["authorization_header"]
}
if hasAuth {
fmt.Printf("- **Authorization Header**: (configured)\n")
}
}
fmt.Printf("- **Events**: %s\n", strings.Join(hook.Events, ", "))
}
-384
View File
@@ -1,384 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"strings"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
)
func TestWebhooksList(t *testing.T) {
now := time.Now()
hooks := []*gitea.Hook{
{
ID: 1,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
},
Events: []string{"push", "pull_request"},
Active: true,
Updated: now,
},
{
ID: 2,
Type: "slack",
Config: map[string]string{
"url": "https://hooks.slack.com/services/xxx",
},
Events: []string{"push"},
Active: false,
Updated: now,
},
{
ID: 3,
Type: "discord",
Config: nil,
Events: []string{"pull_request", "pull_request_review_approved"},
Active: true,
Updated: now,
},
}
// Test that function doesn't panic with various output formats
outputFormats := []string{"table", "csv", "json", "yaml", "simple", "tsv"}
for _, format := range outputFormats {
t.Run("Format_"+format, func(t *testing.T) {
assert.NoError(t, WebhooksList(hooks, format))
})
}
}
func TestWebhooksListEmpty(t *testing.T) {
// Test with empty hook list
hooks := []*gitea.Hook{}
assert.NoError(t, WebhooksList(hooks, "table"))
}
func TestWebhooksListNil(t *testing.T) {
// Test with nil hook list
assert.NoError(t, WebhooksList(nil, "table"))
}
func TestWebhookDetails(t *testing.T) {
now := time.Now()
tests := []struct {
name string
hook *gitea.Hook
}{
{
name: "Complete webhook",
hook: &gitea.Hook{
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
},
},
{
name: "Minimal webhook",
hook: &gitea.Hook{
ID: 456,
Type: "slack",
Config: map[string]string{"url": "https://hooks.slack.com/xxx"},
Events: []string{"push"},
Active: false,
Created: now,
Updated: now,
},
},
{
name: "Webhook with nil config",
hook: &gitea.Hook{
ID: 789,
Type: "discord",
Config: nil,
Events: []string{"pull_request"},
Active: true,
Created: now,
Updated: now,
},
},
{
name: "Webhook with empty config",
hook: &gitea.Hook{
ID: 999,
Type: "gitea",
Config: map[string]string{},
Events: []string{},
Active: false,
Created: now,
Updated: now,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Should not panic
assert.NotPanics(t, func() {
WebhookDetails(tt.hook)
})
})
}
}
func TestWebhookEventsTruncation(t *testing.T) {
tests := []struct {
name string
events []string
maxLength int
shouldTruncate bool
}{
{
name: "Short events list",
events: []string{"push"},
maxLength: 40,
shouldTruncate: false,
},
{
name: "Medium events list",
events: []string{"push", "pull_request"},
maxLength: 40,
shouldTruncate: false,
},
{
name: "Long events list",
events: []string{"push", "pull_request", "pull_request_review_approved", "pull_request_sync", "issues"},
maxLength: 40,
shouldTruncate: true,
},
{
name: "Very long events list",
events: []string{"push", "pull_request", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_comment", "pull_request_assigned", "pull_request_label"},
maxLength: 40,
shouldTruncate: true,
},
{
name: "Empty events",
events: []string{},
maxLength: 40,
shouldTruncate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eventsStr := strings.Join(tt.events, ",")
if len(eventsStr) > tt.maxLength {
assert.True(t, tt.shouldTruncate, "Events string should be truncated")
truncated := eventsStr[:tt.maxLength-3] + "..."
assert.Contains(t, truncated, "...")
assert.LessOrEqual(t, len(truncated), tt.maxLength)
} else {
assert.False(t, tt.shouldTruncate, "Events string should not be truncated")
}
})
}
}
func TestWebhookActiveStatus(t *testing.T) {
tests := []struct {
name string
active bool
expectedSymbol string
}{
{
name: "Active webhook",
active: true,
expectedSymbol: "✓",
},
{
name: "Inactive webhook",
active: false,
expectedSymbol: "✗",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
symbol := "✓"
if !tt.active {
symbol = "✗"
}
assert.Equal(t, tt.expectedSymbol, symbol)
})
}
}
func TestWebhookConfigHandling(t *testing.T) {
tests := []struct {
name string
config map[string]string
expectedURL string
hasSecret bool
hasAuthHeader bool
}{
{
name: "Config with all fields",
config: map[string]string{
"url": "https://example.com/webhook",
"secret": "my-secret",
"content_type": "json",
"http_method": "post",
},
expectedURL: "https://example.com/webhook",
hasSecret: true,
hasAuthHeader: false,
},
{
name: "Config with minimal fields",
config: map[string]string{
"url": "https://hooks.slack.com/xxx",
},
expectedURL: "https://hooks.slack.com/xxx",
hasSecret: false,
hasAuthHeader: false,
},
{
name: "Nil config",
config: nil,
expectedURL: "",
hasSecret: false,
hasAuthHeader: false,
},
{
name: "Empty config",
config: map[string]string{},
expectedURL: "",
hasSecret: false,
hasAuthHeader: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var url string
if tt.config != nil {
url = tt.config["url"]
}
assert.Equal(t, tt.expectedURL, url)
var hasSecret, hasAuthHeader bool
if tt.config != nil {
_, hasSecret = tt.config["secret"]
_, hasAuthHeader = tt.config["authorization_header"]
}
assert.Equal(t, tt.hasSecret, hasSecret)
assert.Equal(t, tt.hasAuthHeader, hasAuthHeader)
})
}
}
func TestWebhookTableHeaders(t *testing.T) {
expectedHeaders := []string{
"ID",
"Type",
"URL",
"Events",
"Active",
"Updated",
}
// Verify all headers are non-empty and unique
headerSet := make(map[string]bool)
for _, header := range expectedHeaders {
assert.NotEmpty(t, header, "Header should not be empty")
assert.False(t, headerSet[header], "Header %s should be unique", header)
headerSet[header] = true
}
assert.Len(t, expectedHeaders, 6, "Should have exactly 6 headers")
}
func TestWebhookTypeValues(t *testing.T) {
validTypes := []string{
"gitea",
"gogs",
"slack",
"discord",
"dingtalk",
"telegram",
"msteams",
"feishu",
"wechatwork",
"packagist",
}
for _, hookType := range validTypes {
t.Run("Type_"+hookType, func(t *testing.T) {
assert.NotEmpty(t, hookType, "Hook type should not be empty")
})
}
}
func TestWebhookDetailsFormatting(t *testing.T) {
now := time.Now()
hook := &gitea.Hook{
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
}
// Test that all expected fields are included in details
expectedElements := []string{
"123", // webhook ID
"gitea", // webhook type
"true", // active status
"https://example.com/webhook", // URL
"json", // content type
"post", // HTTP method
"main,develop", // branch filter
"(configured)", // secret indicator
"(configured)", // auth header indicator
"push, pull_request, issues", // events list
}
// Verify elements exist (placeholder test)
assert.Greater(t, len(expectedElements), 0, "Should have expected elements")
// This is a functional test - in practice, we'd capture output
// For now, we verify the webhook structure contains expected data
assert.Equal(t, int64(123), hook.ID)
assert.Equal(t, "gitea", hook.Type)
assert.True(t, hook.Active)
assert.Equal(t, "https://example.com/webhook", hook.Config["url"])
assert.Equal(t, "json", hook.Config["content_type"])
assert.Equal(t, "post", hook.Config["http_method"])
assert.Equal(t, "main,develop", hook.BranchFilter)
assert.Contains(t, hook.Config, "secret")
assert.Equal(t, "Bearer token123", hook.AuthorizationHeader)
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
}
-209
View File
@@ -1,209 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"encoding/base64"
"fmt"
"strings"
"time"
"gitea.dev/sdk"
)
// WikiPageFields are all available fields to print with WikiPagesList().
var WikiPageFields = []string{
"title",
"path",
"url",
"sha",
"author",
"updated",
"message",
}
// WikiRevisionFields are all available fields to print with WikiRevisionsList().
var WikiRevisionFields = []string{
"sha",
"message",
"author",
"date",
}
// WikiPagesList prints a listing of wiki pages.
func WikiPagesList(pages []*gitea.WikiPageMetaData, output string, fields []string) error {
if len(pages) == 0 && !isMachineReadable(output) {
fmt.Println("No wiki pages found")
return nil
}
t := wikiPagesTable(pages, fields, isMachineReadable(output))
return t.print(output)
}
// WikiPageDetails prints a wiki page.
func WikiPageDetails(page *gitea.WikiPage, output string) error {
if output == "" {
markdown, err := renderWikiPageMarkdown(page)
if err != nil {
return err
}
return outputMarkdown(markdown, getRepoURL(page.HTMLURL))
}
t := wikiPageDetailsTable(page, isMachineReadable(output))
return t.print(output)
}
// WikiRevisionsList prints a listing of wiki page revisions.
func WikiRevisionsList(revisions []*gitea.WikiCommit, output string, fields []string) error {
t := wikiRevisionsTable(revisions, fields, isMachineReadable(output))
return t.print(output)
}
func wikiPagesTable(pages []*gitea.WikiPageMetaData, fields []string, machineReadable bool) table {
printables := make([]printable, len(pages))
for i, page := range pages {
printables[i] = printableWikiPageMetaData{page}
}
return tableFromItems(fields, printables, machineReadable)
}
func wikiPageDetailsTable(page *gitea.WikiPage, machineReadable bool) table {
content, _ := decodeWikiContent(page.ContentBase64)
fields := []string{"title", "content", "commits", "path", "url", "sha", "author", "updated", "message"}
return tableFromItems(fields, []printable{printableWikiPage{page: page, content: content}}, machineReadable)
}
func wikiRevisionsTable(revisions []*gitea.WikiCommit, fields []string, machineReadable bool) table {
printables := make([]printable, len(revisions))
for i, revision := range revisions {
printables[i] = printableWikiRevision{revision}
}
return tableFromItems(fields, printables, machineReadable)
}
func renderWikiPageMarkdown(page *gitea.WikiPage) (string, error) {
content, err := decodeWikiContent(page.ContentBase64)
if err != nil {
return "", err
}
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content, nil
}
func decodeWikiContent(contentBase64 string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(contentBase64)
if err != nil {
return "", err
}
return string(decoded), nil
}
type printableWikiPageMetaData struct{ *gitea.WikiPageMetaData }
type printableWikiPage struct {
page *gitea.WikiPage
content string
}
type printableWikiRevision struct{ *gitea.WikiCommit }
func (x printableWikiPageMetaData) FormatField(field string, machineReadable bool) string {
switch field {
case "title":
return x.Title
case "path":
return x.SubURL
case "url":
return x.HTMLURL
case "sha":
return shortWikiCommitID(x.LastCommit)
case "author":
return wikiCommitAuthor(x.LastCommit)
case "updated":
return wikiCommitDate(x.LastCommit, machineReadable)
case "message":
return wikiCommitMessage(x.LastCommit)
}
return ""
}
func (x printableWikiPage) FormatField(field string, machineReadable bool) string {
switch field {
case "title":
return x.page.Title
case "content":
return x.content
case "commits":
return fmt.Sprintf("%d", x.page.CommitCount)
case "path":
return x.page.SubURL
case "url":
return x.page.HTMLURL
case "sha":
return shortWikiCommitID(x.page.LastCommit)
case "author":
return wikiCommitAuthor(x.page.LastCommit)
case "updated":
return wikiCommitDate(x.page.LastCommit, machineReadable)
case "message":
return wikiCommitMessage(x.page.LastCommit)
}
return ""
}
func (x printableWikiRevision) FormatField(field string, machineReadable bool) string {
switch field {
case "sha":
return shortWikiCommitID(x.WikiCommit)
case "message":
return x.Message
case "author":
return wikiCommitAuthor(x.WikiCommit)
case "date":
return wikiCommitDate(x.WikiCommit, machineReadable)
}
return ""
}
func shortWikiCommitID(commit *gitea.WikiCommit) string {
if commit == nil {
return ""
}
if len(commit.ID) <= 7 {
return commit.ID
}
return commit.ID[:7]
}
func wikiCommitAuthor(commit *gitea.WikiCommit) string {
if commit == nil || commit.Author == nil {
return ""
}
if commit.Author.Name != "" {
return commit.Author.Name
}
return commit.Author.Email
}
func wikiCommitDate(commit *gitea.WikiCommit, machineReadable bool) string {
if commit == nil || commit.Author == nil || commit.Author.Date == "" {
return ""
}
parsed, err := time.Parse(time.RFC3339, commit.Author.Date)
if err != nil {
return commit.Author.Date
}
return FormatTime(parsed, machineReadable)
}
func wikiCommitMessage(commit *gitea.WikiCommit) string {
if commit == nil {
return ""
}
return commit.Message
}
-110
View File
@@ -1,110 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"io"
"os"
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWikiPagesTableJSON(t *testing.T) {
pages := []*gitea.WikiPageMetaData{{
Title: "Home",
HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home",
SubURL: "/octo/tea/wiki/Home",
LastCommit: &gitea.WikiCommit{
ID: "abcdef123456",
Message: "update home",
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "alice"}, Date: "2026-05-21T01:02:03Z"},
},
}}
tbl := wikiPagesTable(pages, []string{"title", "author", "updated", "sha", "url"}, true)
buf := &bytes.Buffer{}
require.NoError(t, tbl.fprint(buf, "json"))
var rows []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 1)
assert.Equal(t, "Home", rows[0]["title"])
assert.Equal(t, "alice", rows[0]["author"])
assert.Equal(t, "2026-05-21T01:02:03Z", rows[0]["updated"])
assert.Equal(t, "abcdef1", rows[0]["sha"])
assert.Equal(t, "https://gitea.example.com/octo/tea/wiki/Home", rows[0]["url"])
}
func TestWikiRevisionsTableJSON(t *testing.T) {
revisions := []*gitea.WikiCommit{{
ID: "1234567890abcdef",
Message: "second revision",
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "bob"}, Date: "2026-05-22T03:04:05Z"},
}}
tbl := wikiRevisionsTable(revisions, []string{"sha", "message", "author", "date"}, true)
buf := &bytes.Buffer{}
require.NoError(t, tbl.fprint(buf, "json"))
var rows []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 1)
assert.Equal(t, "1234567", rows[0]["sha"])
assert.Equal(t, "second revision", rows[0]["message"])
assert.Equal(t, "bob", rows[0]["author"])
assert.Equal(t, "2026-05-22T03:04:05Z", rows[0]["date"])
}
func TestRenderWikiPageMarkdownReturnsDecodedWikiContent(t *testing.T) {
page := &gitea.WikiPage{
Title: "Home",
HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home",
ContentBase64: "IyBIZWxsbyB3aWtp",
LastCommit: &gitea.WikiCommit{
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "carol"}, Date: "2026-05-23T06:07:08Z"},
},
}
out, err := renderWikiPageMarkdown(page)
require.NoError(t, err)
assert.Equal(t, "# Hello wiki\n", out)
}
func TestWikiPagesListEmptyHumanOutput(t *testing.T) {
out := captureStdout(t, func() {
require.NoError(t, WikiPagesList(nil, "", []string{"title"}))
})
assert.Equal(t, "No wiki pages found\n", out)
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
reader, writer, err := os.Pipe()
require.NoError(t, err)
os.Stdout = writer
defer func() {
os.Stdout = oldStdout
}()
done := make(chan string, 1)
go func() {
var outBuf bytes.Buffer
_, _ = io.Copy(&outBuf, reader)
done <- outBuf.String()
}()
fn()
require.NoError(t, writer.Close())
output := <-done
require.NoError(t, reader.Close())
return output
}
-49
View File
@@ -1,49 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
)
// ResolveAssigneeOpts resolves assignee names to IssueAssigneesOption. Returns nil if names is empty.
func ResolveAssigneeOpts(names []string) *gitea.IssueAssigneesOption {
names = cleanAssignees(names)
if len(names) == 0 {
return nil
}
return &gitea.IssueAssigneesOption{Assignees: names}
}
// ApplyAssigneeChanges adds and removes assignees on an issue or pull request.
func ApplyAssigneeChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueAssigneesOption) error {
if rm != nil {
_, _, err := client.Issues.DeleteIssueAssignees(requestCtx, owner, repo, index, *rm)
if err != nil {
return fmt.Errorf("could not remove assignees: %s", err)
}
}
if add != nil {
_, _, err := client.Issues.AddIssueAssignees(requestCtx, owner, repo, index, *add)
if err != nil {
return fmt.Errorf("could not add assignees: %s", err)
}
}
return nil
}
func cleanAssignees(list []string) []string {
out := make([]string, 0, len(list))
for _, a := range list {
if strings.TrimSpace(a) != "" {
out = append(out, a)
}
}
return out
}
-28
View File
@@ -1,28 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
)
// CreateIssue creates an issue in the given repo and returns the created issue
func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) {
// title is required
if len(opts.Title) == 0 {
return nil, fmt.Errorf("title is required")
}
issue, _, err := rlogin.Client().Issues.CreateIssue(requestCtx, repoOwner, repoName, opts)
if err != nil {
return nil, fmt.Errorf("could not create issue: %s", err)
}
return issue, nil
}
-120
View File
@@ -1,120 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
)
// EditIssueOption wraps around gitea.EditIssueOption which has bad & incosistent semantics.
type EditIssueOption struct {
Index int64
Title *string
Body *string
Ref *string
Milestone *string
Deadline *time.Time
AddLabels []string
RemoveLabels []string
SetAssignees []string
AddAssignees []string
RemoveAssignees []string
AddReviewers []string
RemoveReviewers []string
}
// Normalizes the options into parameters that can be passed to the sdk.
// the returned value will be nil, when no change to this part of the issue is requested.
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, *gitea.IssueAssigneesOption, *gitea.IssueAssigneesOption, error) {
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels)
if err != nil {
return nil, nil, nil, nil, nil, err
}
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels)
if err != nil {
return nil, nil, nil, nil, nil, err
}
addAssigneeOpts := ResolveAssigneeOpts(o.AddAssignees)
rmAssigneeOpts := ResolveAssigneeOpts(o.RemoveAssignees)
issueOpts := gitea.EditIssueOption{}
var issueOptsDirty bool
if o.Title != nil {
issueOpts.Title = *o.Title
issueOptsDirty = true
}
if o.Body != nil {
issueOpts.Body = o.Body
issueOptsDirty = true
}
if o.Ref != nil {
issueOpts.Ref = o.Ref
issueOptsDirty = true
}
if o.Milestone != nil {
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone)
if err != nil {
return nil, nil, nil, nil, nil, err
}
issueOpts.Milestone = gitea.OptionalInt64(id)
issueOptsDirty = true
}
if o.Deadline != nil {
issueOpts.Deadline = o.Deadline
issueOptsDirty = true
if o.Deadline.IsZero() {
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
o.SetAssignees = cleanAssignees(o.SetAssignees)
if len(o.SetAssignees) != 0 {
issueOpts.Assignees = o.SetAssignees
issueOptsDirty = true
}
if issueOptsDirty {
return &issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
}
return nil, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
}
// EditIssue edits an issue and returns the updated issue.
func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.Issue, error) {
if client == nil {
client = ctx.Login.Client()
}
issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
if err != nil {
return nil, err
}
if err := ApplyLabelChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
return nil, err
}
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
return nil, err
}
var issue *gitea.Issue
if issueOpts != nil {
issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts)
if err != nil {
return nil, fmt.Errorf("could not edit issue: %s", err)
}
} else {
issue, _, err = client.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index)
if err != nil {
return nil, fmt.Errorf("could not get issue: %s", err)
}
}
return issue, nil
}
-112
View File
@@ -1,112 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/utils"
)
// ResolveLabelNames returns a list of label IDs for a given list of label names
func ResolveLabelNames(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
page := 1
for {
labels, resp, err := client.Repositories.ListRepoLabels(requestCtx, owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
if _, _, err := client.Organizations.GetOrg(requestCtx, owner); err == nil {
orgLabels, _, err := client.Organizations.ListOrgLabels(requestCtx, owner, gitea.ListOrgLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
labels = append(labels, orgLabels...)
}
for _, l := range labels {
if utils.Contains(labelNames, l.Name) {
labelIDs = append(labelIDs, l.ID)
}
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
return labelIDs, nil
}
// ResolveLabelOpts resolves label names to IssueLabelsOption. Returns nil if names is empty.
func ResolveLabelOpts(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, names []string) (*gitea.IssueLabelsOption, error) {
if len(names) == 0 {
return nil, nil
}
ids, err := ResolveLabelNames(requestCtx, client, owner, repo, names)
if err != nil {
return nil, err
}
return &gitea.IssueLabelsOption{Labels: ids}, nil
}
// ApplyLabelChanges adds and removes labels on an issue or pull request.
func ApplyLabelChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueLabelsOption) error {
if rm != nil {
// NOTE: as of 1.17, there is no API to remove multiple labels at once.
for _, id := range rm.Labels {
_, err := client.Issues.DeleteIssueLabel(requestCtx, owner, repo, index, id)
if err != nil {
return fmt.Errorf("could not remove labels: %s", err)
}
}
}
if add != nil {
_, _, err := client.Issues.AddIssueLabels(requestCtx, owner, repo, index, *add)
if err != nil {
return fmt.Errorf("could not add labels: %s", err)
}
}
return nil
}
// ApplyReviewerChanges adds and removes reviewers on a pull request.
func ApplyReviewerChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm []string) error {
if len(rm) != 0 {
_, err := client.PullRequests.DeleteReviewRequests(requestCtx, owner, repo, index, gitea.PullReviewRequestOptions{
Reviewers: rm,
})
if err != nil {
return fmt.Errorf("could not remove reviewers: %w", err)
}
}
if len(add) != 0 {
_, err := client.PullRequests.CreateReviewRequests(requestCtx, owner, repo, index, gitea.PullReviewRequestOptions{
Reviewers: add,
})
if err != nil {
return fmt.Errorf("could not add reviewers: %w", err)
}
}
return nil
}
// ResolveMilestoneID resolves a milestone name to its ID. Returns 0 for empty name.
func ResolveMilestoneID(requestCtx stdctx.Context, client *gitea.Client, owner, repo, name string) (int64, error) {
if name == "" {
return 0, nil
}
ms, _, err := client.Repositories.GetMilestoneByName(requestCtx, owner, repo, name)
if err != nil {
return 0, fmt.Errorf("could not resolve milestone '%s': %w", name, err)
}
return ms.ID, nil
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"os"
"gitea.dev/sdk"
)
// LabelsExport save list of labels to disc
func LabelsExport(labels []*gitea.Label, path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, label := range labels {
if _, err := fmt.Fprintf(f, "#%s %s\n", label.Color, label.Name); err != nil {
return err
}
}
return nil
}
-258
View File
@@ -1,258 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"os"
"os/exec"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/utils"
)
// SetupHelper add tea helper to config global
func SetupHelper(login config.Login) (ok bool, err error) {
// Check that the URL is not blank
if login.URL == "" {
return false, fmt.Errorf("invalid Gitea URL")
}
// get all helper to URL in git config
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
var currentHelpers []byte
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil {
currentHelpers = []byte{}
}
// Check if ared added tea helper
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
return false, nil
}
}
// Add tea helper
if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil {
return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err)
} else if _, err = exec.Command("git", "config", "--global", "--add", helperKey, "!tea login helper").Output(); err != nil {
return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err)
}
return true, nil
}
// HasGitCredentialHelper reports whether tea is registered as a git credential
// helper for the given login. It mirrors the global git config lookup used by
// SetupHelper.
func HasGitCredentialHelper(login config.Login) bool {
if login.URL == "" {
return false
}
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
currentHelpers, err := exec.Command("git", "config", "--global", "--get-all", helperKey).Output()
if err != nil {
return false
}
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
return true
}
}
return false
}
// CreateLogin create a login to be stored in config
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
// checks ...
// ... if we have a url
if len(giteaURL) == 0 {
return fmt.Errorf("Gitea server URL is required")
}
// ... if there already exist a login with same name
if login, err := config.GetLoginByName(name); err != nil {
return err
} else if login != nil {
return fmt.Errorf("login name '%s' has already been used", login.Name)
}
// ... if we already use this token
if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) {
login, err := config.GetLoginByToken(token)
if err != nil {
return err
}
if login != nil {
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
}
}
serverURL, err := utils.ValidateAuthenticationMethod(
giteaURL,
token,
user,
passwd,
sshAgent,
sshKey,
sshCertPrincipal,
)
if err != nil {
return err
}
// check if it's a certificate the principal doesn't matter as the user
// has explicitly selected this private key
if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
sshCertPrincipal = "yes"
}
login := config.Login{
Name: name,
URL: serverURL.String(),
Token: token,
Insecure: insecure,
SSHKey: sshKey,
SSHCertPrincipal: sshCertPrincipal,
SSHKeyFingerprint: sshKeyFingerprint,
SSHAgent: sshAgent,
Created: time.Now().Unix(),
VersionCheck: versionCheck,
}
if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" {
if login.Token, err = generateToken(ctx, login, user, passwd, otp, scopes); err != nil {
return err
}
}
client := login.Client()
// Verify if authentication works and get user info
u, _, err := client.Users.GetMyUserInfo(ctx)
if err != nil {
return err
}
login.User = u.UserName
if len(login.Name) == 0 {
if login.Name, err = GenerateLoginName(giteaURL, login.User); err != nil {
return err
}
}
// we do not have a method to get SSH config from api,
// so we just use the host
login.SSHHost = serverURL.Host
if shouldFindSSHKey(sshKey, sshAgent) {
login.SSHKey, err = findSSHKey(ctx, client)
if err != nil {
fmt.Printf("Warning: problem while finding a SSH key: %s\n", err)
}
}
if err = config.AddLogin(&login); err != nil {
return err
}
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
if addHelper {
if _, err := SetupHelper(login); err != nil {
return err
}
} else {
fmt.Println("Tip: pass --git-credentials (or run 'tea login helper setup') to authenticate 'git push' and 'git clone' over HTTPS with this token.")
}
return nil
}
func shouldFindSSHKey(sshKey string, sshAgent bool) bool {
return sshKey == "" && !sshAgent
}
func shouldCheckTokenUniqueness(token string, sshAgent bool, sshKey, sshCertPrincipal, sshKeyFingerprint string) bool {
if sshAgent || sshKey != "" || sshCertPrincipal != "" || sshKeyFingerprint != "" {
return false
}
return true
}
// generateToken creates a new token when given BasicAuth credentials
func generateToken(ctx stdctx.Context, login config.Login, user, pass, otp, scopes string) (string, error) {
opts := []gitea.ClientOption{gitea.SetBasicAuth(user, pass)}
if otp != "" {
opts = append(opts, gitea.SetOTP(otp))
}
client := login.Client(opts...)
var tl []*gitea.AccessToken
for page := 1; ; {
page_tokens, resp, err := client.Users.ListAccessTokens(ctx, gitea.ListAccessTokensOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return "", err
}
tl = append(tl, page_tokens...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
host, _ := os.Hostname()
tokenName := host + "-tea"
// append timestamp, if a token with this hostname already exists
for i := range tl {
if tl[i].Name == tokenName {
tokenName += time.Now().Format("2006-01-02_15-04-05")
break
}
}
var tokenScopes []gitea.AccessTokenScope
if len(scopes) == 0 {
tokenScopes = []gitea.AccessTokenScope{gitea.AccessTokenScopeAll}
} else {
for _, scope := range strings.Split(scopes, ",") {
tokenScopes = append(tokenScopes, gitea.AccessTokenScope(strings.TrimSpace(scope)))
}
}
t, _, err := client.Users.CreateAccessToken(ctx, gitea.CreateAccessTokenOption{
Name: tokenName,
Scopes: tokenScopes,
})
return t.Token, err
}
// GenerateLoginName generates a name string based on instance URL & adds username if the result is not unique
func GenerateLoginName(url, user string) (string, error) {
parsedURL, err := utils.NormalizeURL(url)
if err != nil {
return "", err
}
name := parsedURL.Host
// append user name if login name already exists
if len(user) != 0 {
if login, err := config.GetLoginByName(name); err != nil {
return "", err
} else if login != nil {
return name + "_" + user, nil
}
}
return name, nil
}
-89
View File
@@ -1,89 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import "testing"
func TestShouldCheckTokenUniqueness(t *testing.T) {
tests := []struct {
name string
token string
sshAgent bool
sshKey string
sshCertPrincipal string
sshKeyFingerprint string
wantCheckUniqueness bool
}{
{
name: "token only",
token: "token",
wantCheckUniqueness: true,
},
{
name: "token with ssh agent",
token: "token",
sshAgent: true,
wantCheckUniqueness: false,
},
{
name: "token with ssh key path",
token: "token",
sshKey: "~/.ssh/id_ed25519",
wantCheckUniqueness: false,
},
{
name: "token with ssh cert principal",
token: "token",
sshCertPrincipal: "principal",
wantCheckUniqueness: false,
},
{
name: "token with ssh key fingerprint",
token: "token",
sshKeyFingerprint: "SHA256:example",
wantCheckUniqueness: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldCheckTokenUniqueness(tt.token, tt.sshAgent, tt.sshKey, tt.sshCertPrincipal, tt.sshKeyFingerprint)
if got != tt.wantCheckUniqueness {
t.Fatalf("expected %v, got %v", tt.wantCheckUniqueness, got)
}
})
}
}
func TestShouldFindSSHKey(t *testing.T) {
tests := []struct {
name string
sshKey string
sshAgent bool
want bool
}{
{
name: "explicit key",
sshKey: "~/.ssh/id_ed25519",
want: false,
},
{
name: "ssh agent",
sshAgent: true,
want: false,
},
{
name: "no key configured",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldFindSSHKey(tt.sshKey, tt.sshAgent); got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
})
}
}
-93
View File
@@ -1,93 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"os"
"path/filepath"
"strings"
"gitea.dev/sdk"
"golang.org/x/crypto/ssh"
"gitea.dev/tea/modules/utils"
)
// ListSSHPubkey lists all the ssh keys in the ssh agent and the ~/.ssh/*.pub files
// It returns a list of SSH keys in the format of:
// "fingerprint keytype comment - principals: principals (ssh-agent or path to pubkey file)"
func ListSSHPubkey() []string {
var keys []string
keys = append(keys, getAgentKeys()...)
keys = append(keys, getLocalKeys()...)
return keys
}
func getAgentKeys() []string {
ag, err := gitea.GetAgent()
if err != nil {
return []string{}
}
akeys, err := ag.List()
if err != nil {
return nil
}
var keys []string
for _, akey := range akeys {
if key := parseKeys([]byte(akey.String()), "ssh-agent"); key != "" {
keys = append(keys, key)
}
}
return keys
}
func getLocalKeys() []string {
var keys []string
// enumerate ~/.ssh/*.pub files
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
if err != nil {
return []string{}
}
localPubkeyPaths, err := filepath.Glob(glob)
if err != nil {
return []string{}
}
// parse each local key with present privkey & compare fingerprints to online keys
for _, pubkeyPath := range localPubkeyPaths {
var pubkeyFile []byte
pubkeyFile, err = os.ReadFile(pubkeyPath)
if err != nil {
continue
}
if key := parseKeys(pubkeyFile, pubkeyPath); key != "" {
keys = append(keys, key)
}
}
return keys
}
func parseKeys(pkinput []byte, sshPath string) string {
pkey, comment, _, _, err := ssh.ParseAuthorizedKey(pkinput)
if err != nil {
return ""
}
if strings.Contains(pkey.Type(), "cert-v01@openssh.com") {
principals := pkey.(*ssh.Certificate).ValidPrincipals
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment +
" - principals: " + strings.Join(principals, ",") + " (" + sshPath + ")"
}
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment + " (" + sshPath + ")"
}
-92
View File
@@ -1,92 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"encoding/base64"
"os"
"path/filepath"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/utils"
"golang.org/x/crypto/ssh"
)
// findSSHKey retrieves the ssh keys registered in gitea, and tries to find
// a matching private key in ~/.ssh/. If no match is found, path is empty.
func findSSHKey(ctx stdctx.Context, client *gitea.Client) (string, error) {
// get keys registered on gitea instance
var keys []*gitea.PublicKey
for page := 1; ; {
page_keys, resp, err := client.Users.ListMyPublicKeys(ctx, gitea.ListPublicKeysOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return "", err
}
keys = append(keys, page_keys...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
if len(keys) == 0 {
return "", nil
}
// enumerate ~/.ssh/*.pub files
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
if err != nil {
return "", err
}
localPubkeyPaths, err := filepath.Glob(glob)
if err != nil {
return "", err
}
// parse each local key with present privkey & compare fingerprints to online keys
for _, pubkeyPath := range localPubkeyPaths {
var pubkeyFile []byte
pubkeyFile, err = os.ReadFile(pubkeyPath)
if err != nil {
continue
}
fields := strings.Split(string(pubkeyFile), " ")
if len(fields) < 2 { // first word is key type, second word is key material
continue
}
var keymaterial []byte
keymaterial, err = base64.StdEncoding.DecodeString(fields[1])
if err != nil {
continue
}
var pubkey ssh.PublicKey
pubkey, err = ssh.ParsePublicKey(keymaterial)
if err != nil {
continue
}
privkeyPath := strings.TrimSuffix(pubkeyPath, ".pub")
var exists bool
exists, err = utils.FileExist(privkeyPath)
if err != nil || !exists {
continue
}
// if pubkey fingerprints match, return path to corresponding privkey.
fingerprint := ssh.FingerprintSHA256(pubkey)
for _, key := range keys {
if fingerprint == key.Fingerprint {
return privkeyPath, nil
}
}
}
return "", err
}
-67
View File
@@ -1,67 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"strings"
"time"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CheckLoginStatus verifies the stored token for a login against the server and
// returns a printable status. Unlike config.Login.Client, refresh failures are
// captured in the returned status instead of terminating the process.
func CheckLoginStatus(ctx context.Context, login *config.Login) print.LoginStatus {
status := print.LoginStatus{
Name: login.Name,
URL: login.URL,
AuthMethod: loginAuthMethod(login),
TokenExpiry: loginTokenExpiry(login),
Helper: HasGitCredentialHelper(*login),
Default: login.Default,
}
if login.GetAccessToken() == "" {
status.Error = "Login failed: no access token configured"
return status
}
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
status.Error = "Token refresh failed: " + strings.TrimPrefix(err.Error(), "failed to refresh token: ")
return status
}
// A successful refresh updates the token in the secure store, so re-read the
// expiry for the status line.
status.TokenExpiry = loginTokenExpiry(login)
user, _, err := login.ClientWithoutRefresh().Users.GetMyUserInfo(ctx)
if err != nil {
status.Error = fmt.Sprintf("Login failed: %s", err)
return status
}
status.Valid = true
status.User = user.UserName
return status
}
func loginAuthMethod(login *config.Login) string {
if login.IsOAuth() {
return config.AuthMethodOAuth
}
return "token"
}
func loginTokenExpiry(login *config.Login) time.Time {
expiry := login.GetTokenExpiry()
if expiry.Equal(time.Unix(0, 0)) {
return time.Time{}
}
return expiry
}
-75
View File
@@ -1,75 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
)
func TestCheckLoginStatus(t *testing.T) {
// Keep helper detection isolated from the developer's real git config.
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), ".gitconfig"))
t.Run("valid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/user", r.URL.Path)
assert.Equal(t, "token secret-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1,"login":"alice"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
})
assert.True(t, status.Valid)
assert.Empty(t, status.Error)
assert.Equal(t, "test", status.Name)
assert.Equal(t, server.URL, status.URL)
assert.Equal(t, "alice", status.User)
assert.Equal(t, "token", status.AuthMethod)
assert.False(t, status.Helper)
})
t.Run("invalid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"token is invalid"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "expired-token",
VersionCheck: false,
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "token is invalid")
})
t.Run("missing token", func(t *testing.T) {
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: "https://gitea.example.com",
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "no access token configured")
})
}
-36
View File
@@ -1,36 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CreateMilestone creates a milestone in the given repo and prints the result
func CreateMilestone(ctx stdctx.Context, login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error {
// title is required
if len(title) == 0 {
return fmt.Errorf("title is required")
}
mile, _, err := login.Client().Repositories.CreateMilestone(ctx, repoOwner, repoName, gitea.CreateMilestoneOption{
Title: title,
Description: description,
Deadline: deadline,
State: state,
})
if err != nil {
return err
}
print.MilestoneDetails(mile)
return nil
}
-158
View File
@@ -1,158 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
local_git "gitea.dev/tea/modules/git"
)
// PullCheckout checkout current workdir to the head branch of specified pull request
func PullCheckout(
ctx stdctx.Context,
login *config.Login,
repoOwner, repoName string,
forceCreateBranch bool,
index int64,
callback func(string) (string, error),
) error {
client := login.Client()
pr, _, err := client.PullRequests.GetPullRequest(ctx, repoOwner, repoName, index)
if err != nil {
return fmt.Errorf("couldn't fetch PR: %s", err)
}
// FIXME: should use ctx.LocalRepo..?
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
// find or create a matching remote
remoteURL := remoteURLForPR(login, pr)
newRemoteName := fmt.Sprintf("pulls/%v", pr.Head.Repository.Owner.UserName)
// verify related remote is in local repo, otherwise add it
localRemote, err := localRepo.GetOrCreateRemote(remoteURL, newRemoteName)
if err != nil {
return err
}
localRemoteName := localRemote.Config().Name
localRemoteBranchName, err := doPRFetch(login, pr, localRepo, localRemote, callback)
if err != nil {
return err
}
return doPRCheckout(localRepo, pr, localRemoteName, localRemoteBranchName, remoteURL, forceCreateBranch)
}
func isRemoteDeleted(pr *gitea.PullRequest) bool {
return pr.Head.Ref == fmt.Sprintf("refs/pull/%d/head", pr.Index)
}
func remoteURLForPR(login *config.Login, pr *gitea.PullRequest) string {
repo := pr.Head.Repository
if isRemoteDeleted(pr) {
repo = pr.Base.Repository
}
if login.SSHKeyPath() != "" || login.SSHAgent {
// Use SSH when a key file is configured, or when the login authenticates
// through a running ssh-agent. In both cases we have a working SSH setup.
return repo.SSHURL
}
return repo.CloneURL
}
func doPRFetch(
login *config.Login,
pr *gitea.PullRequest,
localRepo *local_git.TeaRepo,
localRemote *local_git.Remote,
callback func(string) (string, error),
) (string, error) {
_ = callback
localRemoteName := localRemote.Config().Name
localBranchName := pr.Head.Ref
url, err := localRepo.TeaRemoteURL(localRemoteName)
if err != nil {
return "", err
}
auth, err := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKeyPath(), callback)
if err != nil {
return "", err
}
refspecs := []string{}
if isRemoteDeleted(pr) {
// When the head branch is already deleted, pr.Head.Ref points to
// `refs/pull/<idx>/head`, where the commits stay available.
// This ref must be fetched explicitly, and does not allow pushing, so we use it
// only in this case as fallback.
localBranchName = fmt.Sprintf("pulls/%d", pr.Index)
refspecs = []string{fmt.Sprintf("%s:refs/remotes/%s/%s",
pr.Head.Ref,
localRemoteName,
localBranchName,
)}
}
fmt.Printf("Fetching PR %v (head %s:%s) from remote '%s'\n", pr.Index, url, pr.Head.Ref, localRemoteName)
err = localRepo.Fetch(localRemoteName, refspecs, auth)
if err != nil {
return "", err
}
return localBranchName, nil
}
func doPRCheckout(
localRepo *local_git.TeaRepo,
pr *gitea.PullRequest,
localRemoteName,
localRemoteBranchName,
remoteURL string,
forceCreateBranch bool,
) error {
// determine the ref to checkout, depending on existence of a matching commit on a local branch
var info string
var checkoutRef local_git.ReferenceName
if b, _ := localRepo.TeaFindBranchBySha(pr.Head.Sha, remoteURL); b != nil {
// if a matching branch exists, use that
checkoutRef = local_git.NewBranchReferenceName(b.Name)
info = fmt.Sprintf("Found matching local branch %s, checking it out", checkoutRef.Short())
} else if forceCreateBranch {
// create a branch if wanted
localBranchName := fmt.Sprintf("pulls/%v", pr.Index)
if isRemoteDeleted(pr) {
localBranchName += "-" + pr.Head.Ref
}
checkoutRef = local_git.NewBranchReferenceName(localBranchName)
if err := localRepo.TeaCreateBranch(localBranchName, localRemoteBranchName, localRemoteName); err == nil {
info = fmt.Sprintf("Created branch '%s'\n", localBranchName)
} else if err == local_git.ErrBranchExists {
info = "There may be changes since you last checked out, run `git pull` to get them."
} else {
return err
}
} else {
// use the remote tracking branch
checkoutRef = local_git.NewRemoteReferenceName(localRemoteName, localRemoteBranchName)
info = fmt.Sprintf(
"Checking out remote tracking branch %s. To make changes, create a new branch:\n git checkout %s",
checkoutRef.String(), localRemoteBranchName)
}
fmt.Println(info)
return localRepo.TeaCheckout(checkoutRef)
}
-105
View File
@@ -1,105 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
local_git "gitea.dev/tea/modules/git"
)
// PullClean deletes local & remote feature-branches for a closed pull
func PullClean(ctx stdctx.Context, login *config.Login, repoOwner, repoName string, index int64, ignoreSHA bool, callback func(string) (string, error)) error {
client := login.Client()
repo, _, err := client.Repositories.GetRepo(ctx, repoOwner, repoName)
if err != nil {
return err
}
defaultBranch := repo.DefaultBranch
if len(defaultBranch) == 0 {
defaultBranch = "master"
}
// fetch PR source-repo & -branch from gitea
pr, _, err := client.PullRequests.GetPullRequest(ctx, repoOwner, repoName, index)
if err != nil {
return err
}
if pr.State == gitea.StateOpen {
return fmt.Errorf("PR is still open, won't delete branches")
}
// if remote head branch is already deleted, pr.Head.Ref points to "pulls/<idx>/head"
remoteBranch := pr.Head.Ref
remoteDeleted := isRemoteDeleted(pr)
if remoteDeleted {
remoteBranch = pr.Head.Name // this still holds the original branch name
fmt.Printf("Remote branch '%s' already deleted.\n", remoteBranch)
}
r, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
// find a branch with matching sha or name, that has a remote matching the repo url
var branch *local_git.Branch
if ignoreSHA {
branch, err = r.TeaFindBranchByName(remoteBranch, pr.Head.Repository.CloneURL)
} else {
branch, err = r.TeaFindBranchBySha(pr.Head.Sha, pr.Head.Repository.CloneURL)
}
if err != nil {
return err
}
if branch == nil {
if ignoreSHA {
return fmt.Errorf("remote branch %s not found in local repo", remoteBranch)
}
return fmt.Errorf(`remote branch %s not found in local repo.
Either you don't track this PR, or the local branch has diverged from the remote.
If you still want to continue & are sure you don't loose any important commits,
call me again with the --ignore-sha flag`, remoteBranch)
}
// prepare deletion of local branch:
headRef, err := r.Head()
if err != nil {
return err
}
if headRef.Name().Short() == branch.Name {
fmt.Printf("Checking out '%s' to delete local branch '%s'\n", defaultBranch, branch.Name)
ref := local_git.NewBranchReferenceName(defaultBranch)
if err = r.TeaCheckout(ref); err != nil {
return err
}
}
// remove local & remote branch
fmt.Printf("Deleting local branch %s\n", branch.Name)
err = r.TeaDeleteLocalBranch(branch)
if err != nil {
return err
}
if !remoteDeleted && pr.Head.Repository.Permissions.Push {
fmt.Printf("Deleting remote branch %s\n", remoteBranch)
url, urlErr := r.TeaRemoteURL(branch.Remote)
if urlErr != nil {
return urlErr
}
auth, authErr := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKeyPath(), callback)
if authErr != nil {
return authErr
}
return r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth)
}
return nil
}
-219
View File
@@ -1,219 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"regexp"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
local_git "gitea.dev/tea/modules/git"
"gitea.dev/tea/modules/utils"
)
var (
spaceRegex = regexp.MustCompile(`[\s_-]+`)
noSpace = regexp.MustCompile(`^[^a-zA-Z\s]*`)
consecutive = regexp.MustCompile(`[\s]{2,}`)
)
// CreatePull creates a PR in the given repo and returns the created PR
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (*gitea.PullRequest, error) {
var err error
// default is default branch
if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
if err != nil {
return nil, err
}
}
// default is current one
if len(head) == 0 {
if ctx.LocalRepo == nil {
return nil, fmt.Errorf("no local git repo detected, please specify head branch")
}
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
if err != nil {
return nil, err
}
head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
}
// head & base may not be the same
if head == base {
return nil, fmt.Errorf("can't create PR from %s to %s", head, base)
}
// default is head branch name
if len(opts.Title) == 0 {
opts.Title = GetDefaultPRTitle(head)
}
// title is required
if len(opts.Title) == 0 {
return nil, fmt.Errorf("title is required")
}
client := ctx.Login.Client()
pr, _, err := client.PullRequests.CreatePullRequest(requestCtx, ctx.Owner, ctx.Repo, gitea.CreatePullRequestOption{
Head: head,
Base: base,
Title: opts.Title,
Body: opts.Body,
Assignees: opts.Assignees,
Labels: opts.Labels,
Milestone: opts.Milestone,
Deadline: opts.Deadline,
})
if err != nil {
return nil, fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err)
}
if allowMaintainerEdits != nil && pr.AllowMaintainerEdit != *allowMaintainerEdits {
pr, _, err = client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, pr.Index, gitea.EditPullRequestOption{
AllowMaintainerEdit: allowMaintainerEdits,
})
if err != nil {
return nil, fmt.Errorf("could not enable maintainer edit on pull: %v", err)
}
}
return pr, nil
}
// GetDefaultPRBase retrieves the default base branch for the given repo
func GetDefaultPRBase(requestCtx stdctx.Context, login *config.Login, owner, repo string) (string, error) {
meta, _, err := login.Client().Repositories.GetRepo(requestCtx, owner, repo)
if err != nil {
return "", fmt.Errorf("could not fetch repo meta: %s", err)
}
return meta.DefaultBranch, nil
}
// GetDefaultPRHead uses the currently checked out branch, tries to find a remote
// that has a branch with the same name, and extracts the owner from its URL.
// If no remote matches, owner is empty, meaning same as head repo owner.
func GetDefaultPRHead(localRepo *local_git.TeaRepo) (owner, branch string, err error) {
var sha string
if branch, sha, err = localRepo.TeaGetCurrentBranchNameAndSHA(); err != nil {
return
}
remote, err := localRepo.TeaFindBranchRemote(branch, sha)
if err != nil {
err = fmt.Errorf("could not determine remote for current branch: %s", err)
return
}
if remote == nil {
// if no remote branch is found for the local branch,
// we leave owner empty, meaning "use same repo as head" to gitea.
return
}
url, err := local_git.ParseURL(remote.Config().URLs[0])
if err != nil {
return
}
owner, _ = utils.GetOwnerAndRepo(url.Path, "")
return
}
// GetHeadSpec creates a head string as expected by gitea API
func GetHeadSpec(owner, branch, baseOwner string) string {
if len(owner) != 0 && owner != baseOwner {
return fmt.Sprintf("%s:%s", owner, branch)
}
return branch
}
// GetDefaultPRTitle transforms a string like a branchname to a readable text
func GetDefaultPRTitle(header string) string {
// Extract the part after the last colon in the input string
colonIndex := strings.LastIndex(header, ":")
if colonIndex != -1 {
header = header[colonIndex+1:]
}
title := noSpace.ReplaceAllString(header, "")
title = spaceRegex.ReplaceAllString(title, " ")
title = strings.TrimSpace(title)
title = strings.Title(strings.ToLower(title))
title = consecutive.ReplaceAllString(title, " ")
return title
}
// CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result
func CreateAgitFlowPull(requestCtx stdctx.Context, ctx *context.TeaContext, remote, head, base, topic string,
opts *gitea.CreateIssueOption,
callback func(string) (string, error),
) (err error) {
// default is default branch
if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
if err != nil {
return err
}
}
// default is current one
if len(head) == 0 {
if ctx.LocalRepo == nil {
return fmt.Errorf("no local git repo detected, please specify topic branch")
}
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
if err != nil {
return err
}
head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
}
if len(remote) == 0 {
return fmt.Errorf("remote is required for agit flow PR")
}
if len(topic) == 0 {
topic = head
}
if head == base || topic == base {
return fmt.Errorf("can't create PR from %s to %s", topic, base)
}
// default is head branch name
if len(opts.Title) == 0 {
opts.Title = GetDefaultPRTitle(head)
}
// title is required
if len(opts.Title) == 0 {
return fmt.Errorf("title is required")
}
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
url, err := localRepo.RemoteURL(remote)
if err != nil {
return err
}
auth, err := local_git.GetAuthForURL(url, ctx.Login.GetAccessToken(), ctx.Login.SSHKeyPath(), callback)
if err != nil {
return err
}
return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth)
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import "testing"
func TestGetDefaultPRTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: "Add new feature", want: "Add New Feature"},
{input: "update-docs: Fix typo", want: "Fix Typo"},
{input: "remove_long-string", want: "Remove Long String"},
{input: "Replace_Underscores_With_Spaces", want: "Replace Underscores With Spaces"},
{input: " leading-and-trailing-spaces ", want: "Leading And Trailing Spaces"},
{input: "-----No--Upper--Case-----", want: "No Upper Case"},
{input: "", want: ""},
}
for _, test := range tests {
got := GetDefaultPRTitle(test.input)
if got != test.want {
t.Errorf("GetDefaultPRTitle(%q) = %q, want %q", test.input, got, test.want)
}
}
}
-87
View File
@@ -1,87 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
)
// EditPull edits a pull request and returns the updated pull request.
func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.PullRequest, error) {
if client == nil {
client = ctx.Login.Client()
}
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, opts.AddLabels)
if err != nil {
return nil, err
}
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, opts.RemoveLabels)
if err != nil {
return nil, err
}
addAssigneeOpts := ResolveAssigneeOpts(opts.AddAssignees)
rmAssigneeOpts := ResolveAssigneeOpts(opts.RemoveAssignees)
prOpts := gitea.EditPullRequestOption{}
var prOptsDirty bool
if opts.Title != nil {
prOpts.Title = *opts.Title
prOptsDirty = true
}
if opts.Body != nil {
prOpts.Body = opts.Body
prOptsDirty = true
}
if opts.Milestone != nil {
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *opts.Milestone)
if err != nil {
return nil, err
}
prOpts.Milestone = id
prOptsDirty = true
}
if opts.Deadline != nil {
prOpts.Deadline = opts.Deadline
prOptsDirty = true
if opts.Deadline.IsZero() {
prOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
opts.SetAssignees = cleanAssignees(opts.SetAssignees)
if len(opts.SetAssignees) != 0 {
prOpts.Assignees = opts.SetAssignees
prOptsDirty = true
}
if err := ApplyLabelChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
return nil, err
}
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
return nil, err
}
if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil {
return nil, err
}
var pr *gitea.PullRequest
if prOptsDirty {
pr, _, err = client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, opts.Index, prOpts)
if err != nil {
return nil, fmt.Errorf("could not edit pull request: %s", err)
}
} else {
pr, _, err = client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, opts.Index)
if err != nil {
return nil, fmt.Errorf("could not get pull request: %s", err)
}
}
return pr, nil
}
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff. Show more