Merge branch 'update-with-local-changes'
Some checks failed
goreleaser / goreleaser (push) Failing after 22s
goreleaser / release-image (push) Failing after 2m57s

# Conflicts:
#	cmd/actions.go
#	cmd/actions/runs.go
#	cmd/issues/dependencies.go
#	docs/CLI.md
#	modules/api/client.go
This commit is contained in:
2026-05-02 18:21:23 +02:00
220 changed files with 8427 additions and 2372 deletions

View File

@@ -1,144 +1,106 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package api
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/httputil"
)
// Client provides methods for making raw API calls to Gitea
// Client provides direct HTTP access to Gitea API
type Client struct {
login *config.Login
baseURL string
token string
httpClient *http.Client
}
// NewClient creates a new API client from a login
// NewClient creates a new API client from a Login config
func NewClient(login *config.Login) *Client {
httpClient := &http.Client{}
if login.Insecure {
httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
// 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(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure},
}),
}
return &Client{
login: login,
baseURL: strings.TrimSuffix(login.URL, "/"),
token: login.GetAccessToken(),
httpClient: httpClient,
}
}
// Get makes an authenticated GET request to the API and decodes the JSON response
func (c *Client) Get(ctx context.Context, path string, result interface{}) (*http.Response, error) {
url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path)
// 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.NewRequestWithContext(ctx, "GET", url, nil)
req, err := http.NewRequest(method, reqURL, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "token "+c.login.Token)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return resp, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
// Set authentication header
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return resp, fmt.Errorf("failed to decode response: %w", err)
}
}
return resp, nil
}
// GetRaw makes an authenticated GET request and returns the raw response body
func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "token "+c.login.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
return body, nil
}
// Post makes an authenticated POST request to the API and decodes the JSON response
func (c *Client) Post(ctx context.Context, path string, body io.Reader, result interface{}) (*http.Response, error) {
return c.doRequest(ctx, "POST", path, body, result)
}
// Delete makes an authenticated DELETE request to the API
func (c *Client) Delete(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
return c.doRequest(ctx, "DELETE", path, body, nil)
}
// doRequest performs an HTTP request with the given method
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, result interface{}) (*http.Response, error) {
url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "token "+c.login.Token)
req.Header.Set("Accept", "application/json")
// Set default content type for requests with body
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return resp, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(respBody))
// Apply custom headers (can override defaults)
for key, value := range headers {
req.Header.Set(key, value)
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return resp, fmt.Errorf("failed to decode response: %w", err)
}
}
return resp, nil
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
}

View File

@@ -18,6 +18,7 @@ import (
"time"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/httputil"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
@@ -27,9 +28,6 @@ import (
// Constants for OAuth2 PKCE flow
const (
// default client ID included in most Gitea instances
defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
// default scopes to request
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
@@ -65,7 +63,7 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error {
Name: name,
URL: giteaURL,
Insecure: insecure,
ClientID: defaultClientID,
ClientID: config.DefaultClientID,
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
Port: redirectPort,
}
@@ -74,15 +72,27 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error {
// OAuthLoginWithFullOptions performs an OAuth2 PKCE login flow with full options control
func OAuthLoginWithFullOptions(opts OAuthOptions) error {
// Normalize URL
serverURL, err := utils.NormalizeURL(opts.URL)
serverURL, token, err := performBrowserOAuthFlow(opts)
if err != nil {
return fmt.Errorf("unable to parse URL: %s", err)
return err
}
return createLoginFromToken(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(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 = defaultClientID
opts.ClientID = config.DefaultClientID
}
// If the redirect URL is specified, parse it to extract port if needed
@@ -110,7 +120,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
// Generate code verifier (random string)
codeVerifier, err := generateCodeVerifier(codeVerifierLength)
if err != nil {
return fmt.Errorf("failed to generate code verifier: %s", err)
return "", nil, fmt.Errorf("failed to generate code verifier: %s", err)
}
// Generate code challenge (SHA256 hash of code verifier)
@@ -121,8 +131,8 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(opts.Insecure))
// Configure the OAuth2 endpoints
authURL := fmt.Sprintf("%s/login/oauth/authorize", serverURL)
tokenURL := fmt.Sprintf("%s/login/oauth/access_token", serverURL)
authURL := fmt.Sprintf("%s/login/oauth/authorize", normalizedURL)
tokenURL := fmt.Sprintf("%s/login/oauth/access_token", normalizedURL)
oauth2Config := &oauth2.Config{
ClientID: opts.ClientID,
@@ -144,7 +154,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
// Generate state parameter to protect against CSRF
state, err := generateCodeVerifier(32)
if err != nil {
return fmt.Errorf("failed to generate state: %s", err)
return "", nil, fmt.Errorf("failed to generate state: %s", err)
}
// Get the authorization URL
@@ -159,7 +169,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
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", serverURL)
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")
@@ -168,35 +178,30 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
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 fmt.Errorf("authorization failed: %s", err)
return "", nil, fmt.Errorf("authorization failed: %s", err)
}
// Verify state to prevent CSRF attacks
if state != receivedState {
return fmt.Errorf("state mismatch, possible CSRF attack")
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))
token, err = oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
if err != nil {
return fmt.Errorf("token exchange failed: %s", err)
return "", nil, fmt.Errorf("token exchange failed: %s", err)
}
// Create login with token data
return createLoginFromToken(opts.Name, serverURL.String(), token, opts.Insecure)
return serverURL, token, nil
}
// createHTTPClient creates an HTTP client with optional insecure setting
func createHTTPClient(insecure bool) *http.Client {
client := &http.Client{}
if insecure {
client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
return &http.Client{
Transport: httputil.WrapTransport(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}),
}
return client
}
// generateCodeVerifier creates a cryptographically random string for PKCE
@@ -221,7 +226,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt
codeChan := make(chan string, 1)
stateChan := make(chan string, 1)
errChan := make(chan error, 1)
portChan := make(chan int, 1)
// Parse the redirect URL to get the path
parsedURL, err := url.Parse(opts.RedirectURL)
@@ -306,7 +310,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt
if port == 0 {
addr := listener.Addr().(*net.TCPAddr)
port = addr.Port
portChan <- port
// Update redirect URL with actual port
parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port)
@@ -372,22 +375,17 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure
}
}
// Create login object
// Create login object with OAuth auth method
login := config.Login{
Name: name,
URL: serverURL,
Token: token.AccessToken,
RefreshToken: token.RefreshToken,
Token: token.AccessToken, // temporarily set for Client() validation
AuthMethod: config.AuthMethodOAuth,
Insecure: insecure,
VersionCheck: true,
Created: time.Now().Unix(),
}
// Set token expiry if available
if !token.Expiry.IsZero() {
login.TokenExpiry = token.Expiry.Unix()
}
// Validate token by getting user info
client := login.Client()
u, _, err := client.GetMyUserInfo()
@@ -395,6 +393,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure
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
@@ -410,59 +411,50 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure
return err
}
// Save tokens to credstore
if err := config.SaveOAuthToken(login.Name, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
return fmt.Errorf("failed to save token to secure store: %s", 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 expired access token using the refresh token
// 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 {
if login.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
// Check if token actually needs refreshing
if login.TokenExpiry > 0 && time.Now().Unix() < login.TokenExpiry {
// Token is still valid, no need to refresh
return nil
}
// Create an expired Token object
expiredToken := &oauth2.Token{
AccessToken: login.Token,
RefreshToken: login.RefreshToken,
// Set expiry in the past to force refresh
Expiry: time.Unix(login.TokenExpiry, 0),
}
// Set up the OAuth2 config
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(login.Insecure))
// Configure the OAuth2 endpoints
oauth2Config := &oauth2.Config{
ClientID: defaultClientID,
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", login.URL),
},
}
// Refresh the token
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token()
if err != nil {
return fmt.Errorf("failed to refresh token: %s", err)
}
// Update login with new token information
login.Token = newToken.AccessToken
if newToken.RefreshToken != "" {
login.RefreshToken = newToken.RefreshToken
}
if !newToken.Expiry.IsZero() {
login.TokenExpiry = newToken.Expiry.Unix()
}
// Save updated login to config
return config.UpdateLogin(login)
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(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(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)
}

View File

@@ -5,7 +5,6 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
@@ -40,12 +39,21 @@ type LocalConfig struct {
var (
// config contain if loaded local tea config
config LocalConfig
loadConfigOnce sync.Once
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
@@ -65,12 +73,20 @@ func GetConfigPath() string {
}
if err != nil {
log.Fatal("unable to get or create config file")
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()
@@ -83,22 +99,55 @@ func loadConfig() (err error) {
ymlPath := GetConfigPath()
exist, _ := utils.FileExist(ymlPath)
if exist {
bs, err := os.ReadFile(ymlPath)
if err != nil {
err = fmt.Errorf("Failed to read config file: %s", ymlPath)
bs, readErr := os.ReadFile(ymlPath)
if readErr != nil {
err = fmt.Errorf("failed to read config file %s: %w", ymlPath, readErr)
return
}
err = yaml.Unmarshal(bs, &config)
if err != nil {
err = fmt.Errorf("Failed to parse contents of config file: %s", ymlPath)
if unmarshalErr := yaml.Unmarshal(bs, &config); unmarshalErr != nil {
err = fmt.Errorf("failed to parse config file %s: %w", ymlPath, unmarshalErr)
return
}
}
})
return
}
// saveConfig save config to file
func saveConfig() error {
// 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 {

View File

@@ -0,0 +1,65 @@
// 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-authgate/sdk-go/credstore"
"golang.org/x/oauth2"
)
var (
tokenStore *credstore.SecureStore[credstore.Token]
tokenStoreOnce sync.Once
)
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 getTokenStore().Save(loginName, credstore.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
ClientID: loginName,
})
}
// DeleteOAuthToken removes tokens from the secure store.
func DeleteOAuthToken(loginName string) error {
return getTokenStore().Delete(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
modules/config/lock.go Normal file
View File

@@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"sync"
"time"
"code.gitea.io/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
modules/config/lock_test.go Normal file
View File

@@ -0,0 +1,196 @@
// 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)
}
}

View File

@@ -8,7 +8,6 @@ import (
"crypto/tls"
"errors"
"fmt"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
@@ -18,17 +17,29 @@ import (
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/tea/modules/httputil"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils"
"github.com/charmbracelet/huh"
"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"`
Token string `yaml:"token,omitempty"`
Default bool `yaml:"default"`
SSHHost string `yaml:"ssh_host"`
// optional path to the private key
@@ -43,10 +54,66 @@ type Login struct {
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"`
RefreshToken string `yaml:"refresh_token,omitempty"`
// TokenExpiry is when the token expires (unix timestamp)
TokenExpiry int64 `yaml:"token_expiry"`
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
@@ -64,7 +131,7 @@ func GetDefaultLogin() (*Login, error) {
}
if len(config.Logins) == 0 {
return nil, errors.New("No available login")
return nil, errors.New("no available login")
}
for _, l := range config.Logins {
if l.Default {
@@ -77,192 +144,286 @@ func GetDefaultLogin() (*Login, error) {
// SetDefaultLogin set the default login by name (case insensitive)
func SetDefaultLogin(name string) error {
if err := loadConfig(); err != nil {
return err
}
loginExist := false
for i := range config.Logins {
config.Logins[i].Default = false
if strings.ToLower(config.Logins[i].Name) == strings.ToLower(name) {
config.Logins[i].Default = true
loginExist = true
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)
}
if !loginExist {
return fmt.Errorf("login '%s' not found", name)
}
return saveConfig()
return saveConfigUnsafe()
})
}
// GetLoginByName get login by name (case insensitive)
func GetLoginByName(name string) *Login {
err := loadConfig()
if err != nil {
log.Fatal(err)
func GetLoginByName(name string) (*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
for _, l := range config.Logins {
if strings.ToLower(l.Name) == strings.ToLower(name) {
return &l
for i := range config.Logins {
if strings.EqualFold(config.Logins[i].Name, name) {
return &config.Logins[i], nil
}
}
return nil
return nil, nil
}
// GetLoginByToken get login by token
func GetLoginByToken(token string) *Login {
err := loadConfig()
if err != nil {
log.Fatal(err)
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
return &l, nil
}
}
return nil
return nil, nil
}
// GetLoginByHost finds a login by it's server URL
func GetLoginByHost(host string) *Login {
err := loadConfig()
// GetLoginByHost finds a login by its server URL
func GetLoginByHost(host string) (*Login, error) {
logins, err := GetLoginsByHost(host)
if err != nil {
log.Fatal(err)
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
}
for _, l := range config.Logins {
loginURL, err := url.Parse(l.URL)
var matches []*Login
for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil {
log.Fatal(err)
return nil, err
}
if loginURL.Host == host {
return &l
matches = append(matches, &config.Logins[i])
}
}
return nil
return matches, nil
}
// DeleteLogin delete a login by name from config
func DeleteLogin(name string) error {
idx := -1
for i, l := range config.Logins {
if l.Name == name {
idx = i
break
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)
}
}
if idx == -1 {
return fmt.Errorf("can not delete login '%s', does not exist", name)
}
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
isOAuth := config.Logins[idx].IsOAuth()
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
return saveConfig()
// Clean up credstore tokens for OAuth logins
if isOAuth {
_ = DeleteOAuthToken(name)
}
return saveConfigUnsafe()
})
}
// AddLogin save a login to config
func AddLogin(login *Login) error {
if err := loadConfig(); err != nil {
return err
}
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 global var
config.Logins = append(config.Logins, *login)
// save login to config file
return saveConfig()
// save login to config file
return saveConfigUnsafe()
})
}
// UpdateLogin updates an existing login in the config
func UpdateLogin(login *Login) error {
if err := loadConfig(); err != nil {
return err
// 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())
}
// Find and update the login
found := false
for i, l := range config.Logins {
if l.Name == login.Name {
config.Logins[i] = *login
found = true
break
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()
}
}
}
if !found {
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")
}
// Save updated config
return saveConfig()
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(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(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,
}
ctx := context.Background()
httpClient := &http.Client{
Transport: httputil.WrapTransport(&http.Transport{
TLSClientConfig: &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 {
// Check if token needs refreshing (if we have a refresh token and expiry time)
if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry {
// Since we can't directly call auth.RefreshAccessToken due to import cycles,
// we'll implement the token refresh logic here.
// Create an expired Token object
expiredToken := &oauth2.Token{
AccessToken: l.Token,
RefreshToken: l.RefreshToken,
// Set expiry in the past to force refresh
Expiry: time.Unix(l.TokenExpiry, 0),
}
// Set up the OAuth2 config
ctx := context.Background()
// Create HTTP client with proper insecure settings
httpClient := &http.Client{}
if l.Insecure {
httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Configure the OAuth2 endpoints
oauth2Config := &oauth2.Config{
ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
},
}
// Refresh the token
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token()
if err != nil {
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
}
// Update login with new token information
l.Token = newToken.AccessToken
if newToken.RefreshToken != "" {
l.RefreshToken = newToken.RefreshToken
}
if !newToken.Expiry.IsZero() {
l.TokenExpiry = newToken.Expiry.Unix()
}
// Save updated login to config
if err := UpdateLogin(l); err != nil {
log.Fatalf("Failed to save refreshed token: %s\n", err)
}
// 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)
}
httpClient := &http.Client{}
if l.Insecure {
cookieJar, _ := cookiejar.New(nil)
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{
Jar: cookieJar,
@@ -277,18 +438,24 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
}
options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient))
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 != "" {
l.askForSSHPassphrase()
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.SSHKey, l.SSHPassphrase))
}
if l.SSHKeyFingerprint != "" {
l.askForSSHPassphrase()
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.SSHKey, l.SSHPassphrase))
}
@@ -296,25 +463,25 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
if err != nil {
var versionError *gitea.ErrUnknownVersion
if !errors.As(err, &versionError) {
log.Fatal(err)
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() {
func (l *Login) askForSSHPassphrase() error {
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" {
if err := huh.NewInput().
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(); err != nil {
log.Fatal(err)
}
Run()
}
return nil
}
// GetSSHHost returns SSH host name

View File

@@ -0,0 +1,13 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build testtools
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)
}

View File

@@ -6,25 +6,24 @@ package context
import (
"errors"
"fmt"
"log"
"os"
"path"
"strconv"
"strings"
"time"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
gogit "github.com/go-git/go-git/v5"
"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")
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 {
@@ -41,51 +40,28 @@ type TeaContext struct {
// 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 {
ctx.Ensure(CtxRequirement{RemoteRepo: true})
return path.Join(ctx.Login.URL, ctx.Owner, ctx.Repo)
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
}
// Ensure checks if requirements on the context are set, and terminates otherwise.
func (ctx *TeaContext) Ensure(req CtxRequirement) {
if req.LocalRepo && ctx.LocalRepo == nil {
fmt.Println("Local repository required: Execute from a repo dir, or specify a path with --repo.")
os.Exit(1)
}
if req.RemoteRepo && len(ctx.RepoSlug) == 0 {
fmt.Println("Remote repository required: Specify ID via --repo or execute from a local git repo.")
os.Exit(1)
}
if req.Org && len(ctx.Org) == 0 {
fmt.Println("Organization required: Specify organization via --org.")
os.Exit(1)
}
if req.Global && !ctx.IsGlobal {
fmt.Println("Global scope required: Specify --global.")
os.Exit(1)
}
// 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
}
// 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
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 {
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
// these flags are used as overrides to the context detection via local git repo
repoFlag := cmd.String("repo")
loginFlag := cmd.String("login")
@@ -103,10 +79,12 @@ func InitCommand(cmd *cli.Command) *TeaContext {
// check if repoFlag can be interpreted as path to local repo.
if len(repoFlag) != 0 {
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
log.Fatal(err.Error())
return nil, err
}
if repoFlagPathExists {
repoPath = repoFlag
} else {
c.RepoSlug = repoFlag
}
}
@@ -114,68 +92,74 @@ func InitCommand(cmd *cli.Command) *TeaContext {
remoteFlag = config.GetPreferences().FlagDefaults.Remote
}
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
log.Fatal(err.Error())
// 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)
}
// 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 c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
log.Fatal(err.Error())
if c.RepoSlug == "" {
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
}
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
return nil, err
}
}
}
if len(repoFlag) != 0 && !repoFlagPathExists {
// if repoFlag is not a valid path, use it to override repoSlug
c.RepoSlug = repoFlag
}
// override config user with env variable
envLogin := GetLoginByEnvVar()
// 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 {
_, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "")
if err != nil {
log.Fatal(err.Error())
}
c.Login = envLogin
}
// override login from flag, or use default login if repo based detection failed
if len(loginFlag) != 0 {
c.Login = config.GetLoginByName(loginFlag)
if c.Login, err = config.GetLoginByName(loginFlag); err != nil {
return nil, err
}
if c.Login == nil {
log.Fatalf("Login name '%s' does not exist", loginFlag)
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" {
// TODO: maybe we can directly start interact.CreateLogin() (only if
// we're sure we can interactively!), as gh cli does.
fmt.Println(`No gitea login configured. To start using tea, first run
return nil, fmt.Errorf(`no gitea login configured. To start using tea, first run
tea login add
and then run your command again.`)
and then run your command again`)
}
os.Exit(1)
return nil, err
}
// Only prompt for confirmation if the fallback login is not explicitly set as default
if !c.Login.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 gitea login detected, whether falling back to login '%s'?", c.Login.Name)).
Value(&fallback).
WithTheme(theme.GetTheme()).
Run(); err != nil {
log.Fatalf("Get confirm failed: %v", err)
return nil, fmt.Errorf("get confirm failed: %w", err)
}
if !fallback {
os.Exit(1)
return nil, ErrCommandCanceled
}
} else if !c.Login.Default {
fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
}
}
@@ -185,154 +169,5 @@ and then run your command again.`)
c.IsGlobal = globalFlag
c.Command = cmd
c.Output = cmd.String("output")
return &c
}
// contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo
func contextFromLocalRepo(repoPath, remoteValue string) (*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
}
for _, u := range remoteConfig.URLs {
if l, p, err := MatchLogins(u, logins); err == nil {
return repo, l, p, nil
}
}
return repo, nil, "", errNotAGiteaRepo
}
// 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
}
// 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")
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,
Insecure: insecure,
SSHKey: "",
SSHCertPrincipal: "",
SSHKeyFingerprint: "",
SSHAgent: false,
Created: time.Now().Unix(),
VersionCheck: false,
}
return &c, nil
}

View File

@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"os"
"strconv"
"time"
"code.gitea.io/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,
}
}

View File

@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"testing"
"code.gitea.io/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)
}
})
}
}

View File

@@ -0,0 +1,58 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"strings"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/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
}

View File

@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/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
}

View File

@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"errors"
)
// Ensure checks if requirements on the context are set.
func (ctx *TeaContext) Ensure(req CtxRequirement) error {
if req.LocalRepo && ctx.LocalRepo == nil {
return errors.New("local repository required: execute from a repo dir, or specify a path with --repo")
}
if req.RemoteRepo && len(ctx.RepoSlug) == 0 {
return errors.New("remote repository required: specify id via --repo or execute from a local git repo")
}
if req.Org && len(ctx.Org) == 0 {
return errors.New("organization required: specify organization via --org")
}
if req.Global && !ctx.IsGlobal {
return errors.New("global scope required: specify --global")
}
return nil
}
// CtxRequirement specifies context needed for operation
type CtxRequirement struct {
// ensures a local git repo is available & ctx.LocalRepo is set. Implies .RemoteRepo
LocalRepo bool
// ensures ctx.RepoSlug, .Owner, .Repo are set
RemoteRepo bool
// ensures ctx.Org is set
Org bool
// ensures ctx.IsGlobal is true
Global bool
}

View File

@@ -0,0 +1,113 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"testing"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/git"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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)
})
}

View File

@@ -31,17 +31,37 @@ func Test_MatchLogins(t *testing.T) {
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) {
_, repoPath, err := MatchLogins(kase.remoteURL, kase.logins)
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)
}
})
}
}

View File

@@ -0,0 +1,70 @@
// 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
}

View File

@@ -0,0 +1,92 @@
// 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)
}
}

View File

@@ -0,0 +1,39 @@
// 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)
}

View File

@@ -0,0 +1,48 @@
// 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)
}

View File

@@ -4,8 +4,10 @@
package git
import (
"encoding/base64"
"fmt"
"strings"
"unicode"
"github.com/go-git/go-git/v5"
git_config "github.com/go-git/go-git/v5/config"
@@ -78,7 +80,7 @@ func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *git_config.Branch,
return nil, err
}
if remote == nil {
return nil, fmt.Errorf("No remote found for '%s'", repoURL)
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
}
remoteName := remote.Config().Name
@@ -131,7 +133,7 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config.
return nil, err
}
if remote == nil {
return nil, fmt.Errorf("No remote found for '%s'", repoURL)
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
}
remoteName := remote.Config().Name
@@ -143,7 +145,7 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config.
defer iter.Close()
var remoteRefName git_plumbing.ReferenceName
var localRefName git_plumbing.ReferenceName
var remoteSearchingName = fmt.Sprintf("%s/%s", remoteName, branchName)
remoteSearchingName := fmt.Sprintf("%s/%s", remoteName, branchName)
err = iter.ForEach(func(ref *git_plumbing.Reference) error {
if ref.Name().IsRemote() && ref.Name().Short() == remoteSearchingName {
remoteRefName = ref.Name()
@@ -247,3 +249,47 @@ func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) {
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 git_transport.AuthMethod) error {
if !strings.HasPrefix(head, "refs/") {
head = "refs/heads/" + head
}
ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic)
pushOptions := make(map[string]string)
if len(title) > 0 {
pushOptions["title"] = b64Encode(title)
}
if len(description) > 0 {
pushOptions["description"] = b64Encode(description)
}
opts := &git.PushOptions{
RemoteName: remoteName,
RefSpecs: []git_config.RefSpec{git_config.RefSpec(ref)},
Options: pushOptions,
Auth: auth,
}
return r.Push(opts)
}
// 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
}

View File

@@ -4,6 +4,8 @@
package git
import (
"net/url"
"github.com/go-git/go-git/v5"
)
@@ -33,3 +35,13 @@ func RepoFromPath(path string) (*TeaRepo, error) {
return &TeaRepo{repo}, nil
}
// 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
}
return url.Parse(remote.Config().URLs[0])
}

View File

@@ -1,63 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRepoFromPath_Worktree(t *testing.T) {
// Create a temporary directory for test
tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
mainRepoPath := filepath.Join(tmpDir, "main-repo")
worktreePath := filepath.Join(tmpDir, "worktree")
// Initialize main repository
cmd := exec.Command("git", "init", mainRepoPath)
assert.NoError(t, cmd.Run())
// Configure git for the test
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
assert.NoError(t, cmd.Run())
// Add a remote to the main repository
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
assert.NoError(t, cmd.Run())
// Create an initial commit (required for worktree)
readmePath := filepath.Join(mainRepoPath, "README.md")
err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644)
assert.NoError(t, err)
cmd = exec.Command("git", "-C", mainRepoPath, "add", "README.md")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit")
assert.NoError(t, cmd.Run())
// Create a worktree
cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
assert.NoError(t, cmd.Run())
// Test: Open repository from worktree path
repo, err := RepoFromPath(worktreePath)
assert.NoError(t, err, "Should be able to open worktree")
// Test: Read config from worktree (should read from main repo's config)
config, err := repo.Config()
assert.NoError(t, err, "Should be able to read config")
// Verify that remotes are accessible from worktree
assert.NotEmpty(t, config.Remotes, "Should be able to read remotes from worktree")
assert.Contains(t, config.Remotes, "origin", "Should have origin remote")
assert.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0], "Should have correct remote URL")
}

View File

@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"fmt"
"net/http"
"runtime"
"code.gitea.io/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 wraps an http.RoundTripper to add the User-Agent header.
func WrapTransport(base http.RoundTripper) http.RoundTripper {
if base == nil {
base = http.DefaultTransport
}
return &userAgentTransport{base: base}
}
type userAgentTransport struct {
base http.RoundTripper
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", UserAgent())
return t.base.RoundTrip(req)
}

View File

@@ -13,7 +13,7 @@ import (
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/theme"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
"golang.org/x/term"
)
@@ -21,7 +21,7 @@ import (
// If that flag is unset, and output is not piped, prompts the user first.
func ShowCommentsMaybeInteractive(ctx *context.TeaContext, idx int64, totalComments int) error {
if ctx.Bool("comments") {
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()}
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
c := ctx.Login.Client()
comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, idx, opts)
if err != nil {
@@ -40,7 +40,7 @@ func ShowCommentsMaybeInteractive(ctx *context.TeaContext, idx int64, totalComme
// ShowCommentsPaginated prompts if issue/pr comments should be shown and continues to do so.
func ShowCommentsPaginated(ctx *context.TeaContext, idx int64, totalComments int) error {
c := ctx.Login.Client()
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()}
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
prompt := "show comments?"
commentsLoaded := 0

View File

@@ -11,7 +11,7 @@ import (
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// IsQuitting checks if the user has aborted the interactive prompt
@@ -180,19 +180,25 @@ func fetchIssueSelectables(login *config.Login, owner, repo string, done chan is
r.MilestoneList[i] = m.Title
}
labels, _, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
r.Err = err
done <- r
return
}
r.LabelMap = make(map[string]int64)
r.LabelList = make([]string, len(labels))
for i, l := range labels {
r.LabelMap[l.Name] = l.ID
r.LabelList[i] = l.Name
r.LabelList = make([]string, 0)
for page := 1; ; {
labels, resp, err := c.ListRepoLabels(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

View File

@@ -12,7 +12,7 @@ import (
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// EditIssue interactively edits an issue

View File

@@ -17,7 +17,7 @@ import (
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// CreateLogin create an login interactive
@@ -41,7 +41,7 @@ func CreateLogin() error {
}
_, err := url.Parse(s)
if err != nil {
return fmt.Errorf("Invalid URL: %v", err)
return fmt.Errorf("invalid URL: %v", err)
}
return nil
}).
@@ -69,7 +69,7 @@ func CreateLogin() error {
}
for _, login := range logins {
if login.Name == name {
return fmt.Errorf("Login with name '%s' already exists", name)
return fmt.Errorf("login with name '%s' already exists", name)
}
}
return nil
@@ -86,7 +86,7 @@ func CreateLogin() error {
printTitleAndContent("Name of new Login: ", name)
loginMethod, err := promptSelectV2("Login with: ", []string{"token", "oauth"})
loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate", "oauth"})
if err != nil {
return err
}
@@ -104,7 +104,7 @@ func CreateLogin() error {
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
return auth.OAuthLoginWithOptions(name, giteaURL, insecure)
case "token":
default: // token
var hasToken bool
if err := huh.NewConfirm().
Title("Do you have an access token?").
@@ -176,36 +176,26 @@ func CreateLogin() error {
}
printTitleAndContent("OTP (if applicable):", otp)
}
default:
return fmt.Errorf("unknown login method: %s", loginMethod)
}
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 {
pubKeys := task.ListSSHPubkey()
emptyOpt := "Auto-discovery SSH Key in ~/.ssh and ssh-agent"
pubKeys = append([]string{emptyOpt}, pubKeys...)
sshKey, err = promptSelect("Select ssh-key: ", pubKeys, "", "", "")
if err != nil {
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
}
if sshKey == emptyOpt {
sshKey = ""
}
printTitleAndContent("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):", sshKey)
printTitleAndContent("SSH Key Path (leave empty for auto-discovery) in ~/.ssh and ssh-agent):", sshKey)
if 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)
// ssh certificate
@@ -229,6 +219,27 @@ func CreateLogin() error {
}
}
}
}
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:").

View File

@@ -7,12 +7,12 @@ import (
"fmt"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/sdk/gitea"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// CreateMilestone interactively creates a milestone

View File

@@ -5,16 +5,18 @@ package interact
import (
"fmt"
"os"
"code.gitea.io/tea/modules/theme"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// printTitleAndContent prints a title and content with the gitea theme
func printTitleAndContent(title, content string) {
hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
style := lipgloss.NewStyle().
Foreground(theme.GetTheme().Blurred.Title.GetForeground()).Bold(true).
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
Padding(0, 1)
fmt.Print(style.Render(title), content+"\n")
}

View File

@@ -11,7 +11,8 @@ import (
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// PromptPassword asks for a password and blocks until input was made.

View File

@@ -7,8 +7,9 @@ import (
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// CreatePull interactively creates a PR
@@ -16,6 +17,8 @@ func CreatePull(ctx *context.TeaContext) (err error) {
var (
base, head string
allowMaintainerEdits = true
agit bool
)
// owner, repo
@@ -37,6 +40,66 @@ func CreatePull(ctx *context.TeaContext) (err error) {
}
}
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(ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
return err
}
return task.CreateAgitFlowPull(
ctx,
baseRemote,
head,
base,
topic,
&opts,
PromptPassword,
)
}
if err := huh.NewForm(
huh.NewGroup(
huh.NewInput().

View File

@@ -7,19 +7,19 @@ import (
"fmt"
"strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
// MergePull interactively creates a PR
func MergePull(ctx *context.TeaContext) error {
if ctx.LocalRepo == nil {
return fmt.Errorf("Must specify a PR index")
return fmt.Errorf("pull request index is required")
}
branch, _, err := ctx.LocalRepo.TeaGetCurrentBranchNameAndSHA()
@@ -44,18 +44,21 @@ func getPullIndex(ctx *context.TeaContext, branch string) (int64, error) {
c := ctx.Login.Client()
opts := gitea.ListPullRequestsOptions{
State: gitea.StateOpen,
ListOptions: flags.GetListOptions(),
ListOptions: flags.GetListOptions(ctx.Command),
}
selected := ""
loadMoreOption := "PR not found? Load more PRs..."
// paginated fetch
var prs []*gitea.PullRequest
var err error
for {
var err error
prs, _, err = c.ListRepoPullRequests(ctx.Owner, ctx.Repo, opts)
if err != nil {
return 0, err
}
if len(prs) == 0 {
return 0, fmt.Errorf("No open PRs found")
return 0, fmt.Errorf("no open PRs found")
}
opts.ListOptions.Page++
prOptions := make([]string, 0)

View File

@@ -8,13 +8,13 @@ import (
"os"
"strconv"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/sdk/gitea"
"github.com/charmbracelet/huh"
"charm.land/huh/v2"
)
var reviewStates = map[string]gitea.ReviewStateType{

View File

@@ -7,11 +7,10 @@ import (
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/api"
)
// ActionSecretsList prints a list of action secrets
func ActionSecretsList(secrets []*gitea.Secret, output string) {
func ActionSecretsList(secrets []*gitea.Secret, output string) error {
t := table{
headers: []string{
"Name",
@@ -28,11 +27,11 @@ func ActionSecretsList(secrets []*gitea.Secret, output string) {
if len(secrets) == 0 {
fmt.Printf("No secrets found\n")
return
return nil
}
t.sort(0, true)
t.print(output)
return t.print(output)
}
// ActionVariableDetails prints details of a specific action variable
@@ -44,7 +43,7 @@ func ActionVariableDetails(variable *gitea.RepoActionVariable) {
}
// ActionVariablesList prints a list of action variables
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) {
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) error {
t := table{
headers: []string{
"Name",
@@ -69,100 +68,9 @@ func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) {
if len(variables) == 0 {
fmt.Printf("No variables found\n")
return
return nil
}
t.sort(0, true)
t.print(output)
}
// ActionRunsList prints a list of workflow runs
func ActionRunsList(runs []*api.ActionRun, output string) {
t := table{
headers: []string{
"ID",
"Title",
"Status",
"Conclusion",
"Event",
"Branch",
"Started",
},
}
for _, run := range runs {
conclusion := run.Conclusion
if conclusion == "" {
conclusion = "-"
}
started := ""
if run.StartedAt != nil {
started = FormatTime(*run.StartedAt, output != "")
}
t.addRow(
fmt.Sprintf("%d", run.ID),
run.Title,
run.Status,
conclusion,
run.Event,
run.HeadBranch,
started,
)
}
if len(runs) == 0 {
fmt.Printf("No workflow runs found\n")
return
}
t.print(output)
}
// ActionJobsList prints a list of jobs
func ActionJobsList(jobs []*api.ActionJob, output string) {
t := table{
headers: []string{
"ID",
"Name",
"Status",
"Conclusion",
"Started",
"Completed",
},
}
for _, job := range jobs {
conclusion := job.Conclusion
if conclusion == "" {
conclusion = "-"
}
started := ""
if job.StartedAt != nil {
started = FormatTime(*job.StartedAt, output != "")
}
completed := ""
if job.CompletedAt != nil {
completed = FormatTime(*job.CompletedAt, output != "")
}
t.addRow(
fmt.Sprintf("%d", job.ID),
job.Name,
job.Status,
conclusion,
started,
completed,
)
}
if len(jobs) == 0 {
fmt.Printf("No jobs found\n")
return
}
t.print(output)
return t.print(output)
}

View File

@@ -0,0 +1,215 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"time"
"code.gitea.io/sdk/gitea"
)
// 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)
}
}
}

View File

@@ -0,0 +1,256 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"code.gitea.io/sdk/gitea"
"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)
}
})
}
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"code.gitea.io/sdk/gitea"
"github.com/stretchr/testify/require"
)
func TestActionSecretsListEmpty(t *testing.T) {
@@ -21,7 +22,7 @@ func TestActionSecretsListEmpty(t *testing.T) {
}
}()
ActionSecretsList([]*gitea.Secret{}, "")
require.NoError(t, ActionSecretsList([]*gitea.Secret{}, ""))
}
func TestActionSecretsListWithData(t *testing.T) {
@@ -43,7 +44,7 @@ func TestActionSecretsListWithData(t *testing.T) {
}
}()
ActionSecretsList(secrets, "")
require.NoError(t, ActionSecretsList(secrets, ""))
// Test JSON output format to verify structure
var buf bytes.Buffer
@@ -55,7 +56,7 @@ func TestActionSecretsListWithData(t *testing.T) {
testTable.addRow(secret.Name, FormatTime(secret.Created, true))
}
testTable.fprint(&buf, "json")
require.NoError(t, testTable.fprint(&buf, "json"))
output := buf.String()
if !strings.Contains(output, "TEST_SECRET_1") {
@@ -92,7 +93,7 @@ func TestActionVariablesListEmpty(t *testing.T) {
}
}()
ActionVariablesList([]*gitea.RepoActionVariable{}, "")
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{}, ""))
}
func TestActionVariablesListWithData(t *testing.T) {
@@ -118,7 +119,7 @@ func TestActionVariablesListWithData(t *testing.T) {
}
}()
ActionVariablesList(variables, "")
require.NoError(t, ActionVariablesList(variables, ""))
// Test JSON output format to verify structure and truncation
var buf bytes.Buffer
@@ -134,7 +135,7 @@ func TestActionVariablesListWithData(t *testing.T) {
testTable.addRow(variable.Name, value, strconv.Itoa(int(variable.RepoID)))
}
testTable.fprint(&buf, "json")
require.NoError(t, testTable.fprint(&buf, "json"))
output := buf.String()
if !strings.Contains(output, "TEST_VARIABLE_1") {
@@ -165,7 +166,7 @@ func TestActionVariablesListValueTruncation(t *testing.T) {
}
}()
ActionVariablesList([]*gitea.RepoActionVariable{variable}, "")
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{variable}, ""))
// Test the truncation logic directly
value := variable.Value

View File

@@ -17,7 +17,7 @@ func formatByteSize(size int64) string {
}
// ReleaseAttachmentsList prints a listing of release attachments
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) {
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) error {
t := tableWithHeader(
"Name",
"Size",
@@ -30,5 +30,5 @@ func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) {
)
}
t.print(output)
return t.print(output)
}

View File

@@ -10,8 +10,7 @@ import (
)
// BranchesList prints a listing of the branches
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) {
fmt.Println(fields)
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) error {
printables := make([]printable, len(branches))
for i, branch := range branches {
@@ -25,7 +24,7 @@ func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtectio
}
t := tableFromItems(fields, printables, isMachineReadable(output))
t.print(output)
return t.print(output)
}
type printableBranch struct {
@@ -54,17 +53,17 @@ func (x printableBranch) FormatField(field string, machineReadable bool) string
}
merging := ""
for _, entry := range x.protection.MergeWhitelistTeams {
approving += entry + "/"
merging += entry + "/"
}
for _, entry := range x.protection.MergeWhitelistUsernames {
approving += entry + "/"
merging += entry + "/"
}
pushing := ""
for _, entry := range x.protection.PushWhitelistTeams {
approving += entry + "/"
pushing += entry + "/"
}
for _, entry := range x.protection.PushWhitelistUsernames {
approving += entry + "/"
pushing += entry + "/"
}
return fmt.Sprintf(
"- enable-push: %t\n- approving: %s\n- merging: %s\n- pushing: %s\n",

View File

@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"code.gitea.io/sdk/gitea"
"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/")
}

View File

@@ -45,8 +45,8 @@ func formatReactions(reactions []*gitea.Reaction) string {
}
// IssuesPullsList prints a listing of issues & pulls
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) {
printIssues(issues, output, fields)
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) error {
return printIssues(issues, output, fields)
}
// IssueFields are all available fields to print with IssuesList()
@@ -73,7 +73,7 @@ var IssueFields = []string{
"repo",
}
func printIssues(issues []*gitea.Issue, output string, fields []string) {
func printIssues(issues []*gitea.Issue, output string, fields []string) error {
labelMap := map[int64]string{}
printables := make([]printable, len(issues))
machineReadable := isMachineReadable(output)
@@ -90,7 +90,7 @@ func printIssues(issues []*gitea.Issue, output string, fields []string) {
}
t := tableFromItems(fields, printables, machineReadable)
t.print(output)
return t.print(output)
}
type printableIssue struct {

View File

@@ -10,7 +10,7 @@ import (
)
// LabelsList prints a listing of labels
func LabelsList(labels []*gitea.Label, output string) {
func LabelsList(labels []*gitea.Label, output string) error {
t := tableWithHeader(
"Index",
"Color",
@@ -26,5 +26,5 @@ func LabelsList(labels []*gitea.Label, output string) {
label.Description,
)
}
t.print(output)
return t.print(output)
}

View File

@@ -31,7 +31,7 @@ func LoginDetails(login *config.Login) {
}
// LoginsList prints a listing of logins
func LoginsList(logins []config.Login, output string) {
func LoginsList(logins []config.Login, output string) error {
t := tableWithHeader(
"Name",
"URL",
@@ -50,5 +50,5 @@ func LoginsList(logins []config.Login, output string) {
)
}
t.print(output)
return t.print(output)
}

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"os"
"github.com/charmbracelet/glamour"
"charm.land/glamour/v2"
"golang.org/x/term"
)
@@ -15,19 +15,23 @@ import (
// If the input could not be parsed, it is printed unformatted, the error
// is returned anyway.
func outputMarkdown(markdown string, baseURL string) error {
var styleOption glamour.TermRendererOption
var renderer *glamour.TermRenderer
var err error
if IsInteractive() {
styleOption = glamour.WithAutoStyle()
renderer, err = glamour.NewTermRenderer(
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
} else {
styleOption = glamour.WithStandardStyle("notty")
renderer, err = glamour.NewTermRenderer(
glamour.WithStandardStyle("notty"),
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
}
renderer, err := glamour.NewTermRenderer(
styleOption,
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
glamour.WithWordWrap(getWordWrap()),
)
if err != nil {
fmt.Print(markdown)
return err

View File

@@ -23,14 +23,14 @@ func MilestoneDetails(milestone *gitea.Milestone) {
}
// MilestonesList prints a listing of milestones
func MilestonesList(news []*gitea.Milestone, output string, fields []string) {
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)
t.print(output)
return t.print(output)
}
// MilestoneFields are all available fields to print with MilestonesList

View File

@@ -11,13 +11,13 @@ import (
)
// NotificationsList prints a listing of notification threads
func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) {
var printables = make([]printable, len(news))
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))
t.print(output)
return t.print(output)
}
// NotificationFields are all available fields to print with NotificationsList

View File

@@ -22,10 +22,10 @@ func OrganizationDetails(org *gitea.Organization) {
}
// OrganizationsList prints a listing of the organizations
func OrganizationsList(organizations []*gitea.Organization, output string) {
func OrganizationsList(organizations []*gitea.Organization, output string) error {
if len(organizations) == 0 {
fmt.Println("No organizations found")
return
return nil
}
t := tableWithHeader(
@@ -46,5 +46,5 @@ func OrganizationsList(organizations []*gitea.Organization, output string) {
)
}
t.print(output)
return t.print(output)
}

View File

@@ -12,7 +12,7 @@ import (
var ciStatusSymbols = map[gitea.StatusState]string{
gitea.StatusSuccess: "✓ ",
gitea.StatusPending: " ",
gitea.StatusPending: " ",
gitea.StatusWarning: "⚠ ",
gitea.StatusError: "✘ ",
gitea.StatusFailure: "❌ ",
@@ -42,16 +42,19 @@ func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *g
out += formatReviews(pr, reviews)
if ciStatus != nil {
var summary, errors string
if ciStatus != nil && len(ciStatus.Statuses) != 0 {
out += "- CI:\n"
for _, s := range ciStatus.Statuses {
summary += ciStatusSymbols[s.State]
if s.State != gitea.StatusSuccess {
errors += fmt.Sprintf(" - [**%s**:\t%s](%s)\n", s.Context, s.Description, s.TargetURL)
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 len(ciStatus.Statuses) != 0 {
out += fmt.Sprintf("- CI: %s\n%s", summary, errors)
if s.Description != "" {
out += fmt.Sprintf(": %s", s.Description)
}
out += "\n"
}
}
@@ -89,6 +92,20 @@ func formatPRState(pr *gitea.PullRequest) string {
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 {
@@ -111,7 +128,6 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)] = review
}
}
}
}
@@ -139,8 +155,8 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
}
// PullsList prints a listing of pulls
func PullsList(prs []*gitea.PullRequest, output string, fields []string) {
printPulls(prs, output, fields)
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()
@@ -169,11 +185,12 @@ var PullFields = []string{
"milestone",
"labels",
"comments",
"ci",
}
func printPulls(pulls []*gitea.PullRequest, output string, fields []string) {
func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
labelMap := map[int64]string{}
var printables = make([]printable, len(pulls))
printables := make([]printable, len(pulls))
machineReadable := isMachineReadable(output)
for i, x := range pulls {
@@ -184,16 +201,17 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) {
}
}
// store items with printable interface
printables[i] = &printablePull{x, &labelMap}
printables[i] = &printablePull{x, &labelMap, &ciStatuses}
}
t := tableFromItems(fields, printables, machineReadable)
t.print(output)
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 {
@@ -227,13 +245,13 @@ func (x printablePull) FormatField(field string, machineReadable bool) string {
}
return ""
case "labels":
var labels = make([]string, len(x.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":
var assignees = make([]string, len(x.Assignees))
assignees := make([]string, len(x.Assignees))
for i, a := range x.Assignees {
assignees[i] = formatUserName(a)
}
@@ -253,6 +271,13 @@ func (x printablePull) FormatField(field string, machineReadable bool) string {
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 ""
}

View File

@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"code.gitea.io/sdk/gitea"
)
// 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 ""
}

189
modules/print/pull_test.go Normal file
View File

@@ -0,0 +1,189 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"slices"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"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 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'")
}

View File

@@ -8,7 +8,7 @@ import (
)
// ReleasesList prints a listing of releases
func ReleasesList(releases []*gitea.Release, output string) {
func ReleasesList(releases []*gitea.Release, output string) error {
t := tableWithHeader(
"Tag-Name",
"Title",
@@ -33,5 +33,5 @@ func ReleasesList(releases []*gitea.Release, output string) {
)
}
t.print(output)
return t.print(output)
}

View File

@@ -12,13 +12,13 @@ import (
)
// ReposList prints a listing of the repos
func ReposList(repos []*gitea.Repository, output string, fields []string) {
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))
t.print(output)
return t.print(output)
}
// RepoDetails print an repo formatted to stdout
@@ -113,7 +113,7 @@ func (x printableRepo) FormatField(field string, machineReadable bool) string {
case "forks":
return fmt.Sprintf("%d", x.Forks)
case "id":
return x.FullName
return fmt.Sprintf("%d", x.ID)
case "name":
return x.Name
case "owner":

View File

@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"testing"
"code.gitea.io/sdk/gitea"
"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"])
}

View File

@@ -4,6 +4,8 @@
package print
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
@@ -14,6 +16,7 @@ import (
"strings"
"github.com/olekukonko/tablewriter"
"gopkg.in/yaml.v3"
)
// table provides infrastructure to easily print (sorted) lists in different formats
@@ -72,34 +75,26 @@ func (t table) Less(i, j int) bool {
return t.values[i][t.sortColumn] < t.values[j][t.sortColumn]
}
func (t *table) print(output string) {
t.fprint(os.Stdout, output)
func (t *table) print(output string) error {
return t.fprint(os.Stdout, output)
}
func (t *table) fprint(f io.Writer, output string) {
func (t *table) fprint(f io.Writer, output string) error {
switch output {
case "", "table":
outputTable(f, t.headers, t.values)
return outputTable(f, t.headers, t.values)
case "csv":
outputDsv(f, t.headers, t.values, ",")
return outputDsv(f, t.headers, t.values, ',')
case "simple":
outputSimple(f, t.headers, t.values)
return outputSimple(f, t.headers, t.values)
case "tsv":
outputDsv(f, t.headers, t.values, "\t")
return outputDsv(f, t.headers, t.values, '\t')
case "yml", "yaml":
outputYaml(f, t.headers, t.values)
return outputYaml(f, t.headers, t.values)
case "json":
outputJSON(f, t.headers, t.values)
return outputJSON(f, t.headers, t.values)
default:
fmt.Fprintf(f, `"unknown output type '%s', available types are:
- csv: comma-separated values
- simple: space-separated values
- table: auto-aligned table format (default)
- tsv: tab-separated values
- yaml: YAML format
- json: JSON format
`, output)
os.Exit(1)
return fmt.Errorf("unknown output type %q, available types are: csv, simple, table, tsv, yaml, json", output)
}
}
@@ -118,41 +113,58 @@ func outputTable(f io.Writer, headers []string, values [][]string) error {
}
// outputSimple prints structured data as space delimited value
func outputSimple(f io.Writer, headers []string, values [][]string) {
func outputSimple(f io.Writer, headers []string, values [][]string) error {
for _, value := range values {
fmt.Fprint(f, strings.Join(value, " "))
fmt.Fprintf(f, "\n")
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, delimiterOpt ...string) {
delimiter := ","
if len(delimiterOpt) > 0 {
delimiter = delimiterOpt[0]
// 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
}
fmt.Fprintln(f, "\""+strings.Join(headers, "\""+delimiter+"\"")+"\"")
for _, value := range values {
fmt.Fprintf(f, "\"")
fmt.Fprint(f, strings.Join(value, "\""+delimiter+"\""))
fmt.Fprintf(f, "\"")
fmt.Fprintf(f, "\n")
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) {
func outputYaml(f io.Writer, headers []string, values [][]string) error {
root := &yaml.Node{Kind: yaml.SequenceNode}
for _, value := range values {
fmt.Fprintln(f, "-")
row := &yaml.Node{Kind: yaml.MappingNode}
for j, val := range value {
intVal, _ := strconv.Atoi(val)
if strconv.Itoa(intVal) == val {
fmt.Fprintf(f, " %s: %s\n", headers[j], val)
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 {
fmt.Fprintf(f, " %s: '%s'\n", headers[j], strings.ReplaceAll(val, "'", "''"))
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 (
@@ -166,42 +178,52 @@ func toSnakeCase(str string) string {
return strings.ToLower(snake)
}
// outputJSON prints structured data as json
// Since golang's map is unordered, we need to ensure consistent ordering, we have
// to output the JSON ourselves.
func outputJSON(f io.Writer, headers []string, values [][]string) {
fmt.Fprintln(f, "[")
itemCount := len(values)
headersCount := len(headers)
const space = " "
for i, value := range values {
fmt.Fprintf(f, "%s{\n", space)
for j, val := range value {
v, err := json.Marshal(val)
if err != nil {
fmt.Printf("Failed to format JSON for value '%s': %v\n", val, err)
return
}
key, err := json.Marshal(toSnakeCase(headers[j]))
if err != nil {
fmt.Printf("Failed to format JSON for header '%s': %v\n", headers[j], err)
return
}
fmt.Fprintf(f, "%s:%s", key, v)
if j != headersCount-1 {
fmt.Fprintln(f, ",")
} else {
fmt.Fprintln(f)
}
}
// orderedRow preserves header insertion order when marshaled to JSON.
type orderedRow struct {
keys []string
values map[string]string
}
if i != itemCount-1 {
fmt.Fprintf(f, "%s},\n", space)
} else {
fmt.Fprintf(f, "%s}\n", space)
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)
}
fmt.Fprintln(f, "]")
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 {

View File

@@ -5,10 +5,14 @@ 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) {
@@ -29,7 +33,7 @@ func TestPrint(t *testing.T) {
buf := &bytes.Buffer{}
tData.fprint(buf, "json")
require.NoError(t, tData.fprint(buf, "json"))
result := []struct {
A string
B string
@@ -51,22 +55,62 @@ func TestPrint(t *testing.T) {
buf.Reset()
tData.fprint(buf, "yaml")
require.NoError(t, tData.fprint(buf, "yaml"))
assert.Equal(t, `-
A: 'new a'
B: 'some bbbb'
-
A: 'AAAAA'
B: 'b2'
-
A: '"abc'
B: '"def'
-
A: '''abc'
B: 'de''f'
-
A: '\abc'
B: '''def\'
`, buf.String())
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"`)
}

View File

@@ -10,8 +10,8 @@ import (
)
// TrackedTimesList print list of tracked times to stdout
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) {
var printables = make([]printable, len(times))
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
@@ -26,7 +26,7 @@ func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []st
t.addRowSlice(total)
}
t.print(outputType)
return t.print(outputType)
}
// TrackedTimeFields contains all available fields for printing of tracked times.

View File

@@ -52,13 +52,13 @@ func UserDetails(user *gitea.User) {
}
// UserList prints a listing of the users
func UserList(user []*gitea.User, output string, fields []string) {
var printables = make([]printable, len(user))
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))
t.print(output)
return t.print(output)
}
// UserFields are the available fields to print with UserList()

View File

@@ -12,7 +12,7 @@ import (
)
// WebhooksList prints a listing of webhooks
func WebhooksList(hooks []*gitea.Hook, output string) {
func WebhooksList(hooks []*gitea.Hook, output string) error {
t := tableWithHeader(
"ID",
"Type",
@@ -48,7 +48,7 @@ func WebhooksList(hooks []*gitea.Hook, output string) {
)
}
t.print(output)
return t.print(output)
}
// WebhookDetails prints detailed information about a webhook
@@ -67,13 +67,21 @@ func WebhookDetails(hook *gitea.Hook) {
if method, ok := hook.Config["http_method"]; ok {
fmt.Printf("- **HTTP Method**: %s\n", method)
}
if branchFilter, ok := hook.Config["branch_filter"]; ok && branchFilter != "" {
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")
}
if _, hasAuth := hook.Config["authorization_header"]; hasAuth {
hasAuth := hook.AuthorizationHeader != ""
if !hasAuth {
_, hasAuth = hook.Config["authorization_header"]
}
if hasAuth {
fmt.Printf("- **Authorization Header**: (configured)\n")
}
}

View File

@@ -51,10 +51,7 @@ func TestWebhooksList(t *testing.T) {
for _, format := range outputFormats {
t.Run("Format_"+format, func(t *testing.T) {
// Should not panic
assert.NotPanics(t, func() {
WebhooksList(hooks, format)
})
assert.NoError(t, WebhooksList(hooks, format))
})
}
}
@@ -63,16 +60,12 @@ func TestWebhooksListEmpty(t *testing.T) {
// Test with empty hook list
hooks := []*gitea.Hook{}
assert.NotPanics(t, func() {
WebhooksList(hooks, "table")
})
assert.NoError(t, WebhooksList(hooks, "table"))
}
func TestWebhooksListNil(t *testing.T) {
// Test with nil hook list
assert.NotPanics(t, func() {
WebhooksList(nil, "table")
})
assert.NoError(t, WebhooksList(nil, "table"))
}
func TestWebhookDetails(t *testing.T) {
@@ -88,17 +81,17 @@ func TestWebhookDetails(t *testing.T) {
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"branch_filter": "main,develop",
"secret": "secret-value",
"authorization_header": "Bearer token123",
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
},
},
{
@@ -245,16 +238,14 @@ func TestWebhookConfigHandling(t *testing.T) {
{
name: "Config with all fields",
config: map[string]string{
"url": "https://example.com/webhook",
"secret": "my-secret",
"authorization_header": "Bearer token",
"content_type": "json",
"http_method": "post",
"branch_filter": "main",
"url": "https://example.com/webhook",
"secret": "my-secret",
"content_type": "json",
"http_method": "post",
},
expectedURL: "https://example.com/webhook",
hasSecret: true,
hasAuthHeader: true,
hasAuthHeader: false,
},
{
name: "Config with minimal fields",
@@ -348,17 +339,17 @@ func TestWebhookDetailsFormatting(t *testing.T) {
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"branch_filter": "main,develop",
"secret": "secret-value",
"authorization_header": "Bearer token123",
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
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
@@ -386,8 +377,8 @@ func TestWebhookDetailsFormatting(t *testing.T) {
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.Config["branch_filter"])
assert.Equal(t, "main,develop", hook.BranchFilter)
assert.Contains(t, hook.Config, "secret")
assert.Contains(t, hook.Config, "authorization_header")
assert.Equal(t, "Bearer token123", hook.AuthorizationHeader)
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
}

View File

@@ -15,7 +15,7 @@ import (
func CreateIssue(login *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error {
// title is required
if len(opts.Title) == 0 {
return fmt.Errorf("Title is required")
return fmt.Errorf("title is required")
}
issue, _, err := login.Client().CreateIssue(repoOwner, repoName, opts)

View File

@@ -13,37 +13,30 @@ import (
// 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
AddAssignees []string
Index int64
Title *string
Body *string
Ref *string
Milestone *string
Deadline *time.Time
AddLabels []string
RemoveLabels []string
AddAssignees []string
AddReviewers []string
RemoveReviewers []string
// RemoveAssignees []string // NOTE: with the current go-sdk, clearing assignees is not possible.
}
// 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(ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, error) {
// labels have a separate API call, so they get their own options.
var addLabelOpts, rmLabelOpts *gitea.IssueLabelsOption
if o.AddLabels != nil && len(o.AddLabels) != 0 {
ids, err := ResolveLabelNames(client, ctx.Owner, ctx.Repo, o.AddLabels)
if err != nil {
return nil, nil, nil, err
}
addLabelOpts = &gitea.IssueLabelsOption{Labels: ids}
addLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, o.AddLabels)
if err != nil {
return nil, nil, nil, err
}
if o.RemoveLabels != nil && len(o.RemoveLabels) != 0 {
ids, err := ResolveLabelNames(client, ctx.Owner, ctx.Repo, o.RemoveLabels)
if err != nil {
return nil, nil, nil, err
}
rmLabelOpts = &gitea.IssueLabelsOption{Labels: ids}
rmLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, o.RemoveLabels)
if err != nil {
return nil, nil, nil, err
}
issueOpts := gitea.EditIssueOption{}
@@ -61,15 +54,11 @@ func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Cli
issueOptsDirty = true
}
if o.Milestone != nil {
if *o.Milestone == "" {
issueOpts.Milestone = gitea.OptionalInt64(0)
} else {
ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, *o.Milestone)
if err != nil {
return nil, nil, nil, fmt.Errorf("Milestone '%s' not found", *o.Milestone)
}
issueOpts.Milestone = &ms.ID
id, err := ResolveMilestoneID(client, ctx.Owner, ctx.Repo, *o.Milestone)
if err != nil {
return nil, nil, nil, err
}
issueOpts.Milestone = gitea.OptionalInt64(id)
issueOptsDirty = true
}
if o.Deadline != nil {
@@ -79,7 +68,7 @@ func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Cli
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
if o.AddAssignees != nil && len(o.AddAssignees) != 0 {
if len(o.AddAssignees) != 0 {
issueOpts.Assignees = o.AddAssignees
issueOptsDirty = true
}
@@ -101,21 +90,8 @@ func EditIssue(ctx *context.TeaContext, client *gitea.Client, opts EditIssueOpti
return nil, err
}
if rmLabelOpts != nil {
// NOTE: as of 1.17, there is no API to remove multiple labels at once.
for _, id := range rmLabelOpts.Labels {
_, err := client.DeleteIssueLabel(ctx.Owner, ctx.Repo, opts.Index, id)
if err != nil {
return nil, fmt.Errorf("could not remove labels: %s", err)
}
}
}
if addLabelOpts != nil {
_, _, err := client.AddIssueLabels(ctx.Owner, ctx.Repo, opts.Index, *addLabelOpts)
if err != nil {
return nil, fmt.Errorf("could not add labels: %s", err)
}
if err := ApplyLabelChanges(client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
return nil, err
}
var issue *gitea.Issue

View File

@@ -4,6 +4,8 @@
package task
import (
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/utils"
)
@@ -11,16 +13,88 @@ import (
// ResolveLabelNames returns a list of label IDs for a given list of label names
func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
for _, l := range labels {
if utils.Contains(labelNames, l.Name) {
labelIDs = append(labelIDs, l.ID)
page := 1
for {
labels, resp, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
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(client *gitea.Client, owner, repo string, names []string) (*gitea.IssueLabelsOption, error) {
if len(names) == 0 {
return nil, nil
}
ids, err := ResolveLabelNames(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(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.DeleteIssueLabel(owner, repo, index, id)
if err != nil {
return fmt.Errorf("could not remove labels: %s", err)
}
}
}
if add != nil {
_, _, err := client.AddIssueLabels(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(client *gitea.Client, owner, repo string, index int64, add, rm []string) error {
if len(rm) != 0 {
_, err := client.DeleteReviewRequests(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.CreateReviewRequests(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(client *gitea.Client, owner, repo, name string) (int64, error) {
if name == "" {
return 0, nil
}
ms, _, err := client.GetMilestoneByName(owner, repo, name)
if err != nil {
return 0, fmt.Errorf("could not resolve milestone '%s': %w", name, err)
}
return ms.ID, nil
}

View File

@@ -20,12 +20,13 @@ import (
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")
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", fmt.Sprintf("credential.%s.helper", login.URL)).Output(); err != nil {
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil {
currentHelpers = []byte{}
}
@@ -37,10 +38,10 @@ func SetupHelper(login config.Login) (ok bool, err error) {
}
// Add tea helper
if _, err = exec.Command("git", "config", "--global", fmt.Sprintf("credential.%s.helper", login.URL), "").Output(); err != nil {
return false, fmt.Errorf("git config --global %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), err)
} else if _, err = exec.Command("git", "config", "--global", "--add", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper").Output(); err != nil {
return false, fmt.Errorf("git config --global --add %s %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper", err)
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
@@ -51,16 +52,24 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe
// checks ...
// ... if we have a url
if len(giteaURL) == 0 {
return fmt.Errorf("You have to input Gitea server URL")
return fmt.Errorf("Gitea server URL is required")
}
// ... if there already exist a login with same name
if login := config.GetLoginByName(name); login != nil {
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 login := config.GetLoginByToken(token); login != nil {
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
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(
@@ -68,6 +77,9 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe
token,
user,
passwd,
sshAgent,
sshKey,
sshCertPrincipal,
)
if err != nil {
return err
@@ -92,7 +104,7 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe
VersionCheck: versionCheck,
}
if len(token) == 0 {
if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" {
if login.Token, err = generateToken(login, user, passwd, otp, scopes); err != nil {
return err
}
@@ -138,6 +150,14 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe
return nil
}
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(login config.Login, user, pass, otp, scopes string) (string, error) {
opts := []gitea.ClientOption{gitea.SetBasicAuth(user, pass)}
@@ -146,11 +166,19 @@ func generateToken(login config.Login, user, pass, otp, scopes string) (string,
}
client := login.Client(opts...)
tl, _, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return "", err
var tl []*gitea.AccessToken
for page := 1; ; {
page_tokens, resp, err := client.ListAccessTokens(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"
@@ -189,7 +217,9 @@ func GenerateLoginName(url, user string) (string, error) {
// append user name if login name already exists
if len(user) != 0 {
if login := config.GetLoginByName(name); login != nil {
if login, err := config.GetLoginByName(name); err != nil {
return "", err
} else if login != nil {
return name + "_" + user, nil
}
}

View File

@@ -0,0 +1,57 @@
// 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)
}
})
}
}

View File

@@ -19,11 +19,22 @@ import (
// a matching private key in ~/.ssh/. If no match is found, path is empty.
func findSSHKey(client *gitea.Client) (string, error) {
// get keys registered on gitea instance
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil || len(keys) == 0 {
return "", err
var keys []*gitea.PublicKey
for page := 1; ; {
page_keys, resp, err := client.ListMyPublicKeys(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

View File

@@ -15,10 +15,9 @@ import (
// CreateMilestone creates a milestone in the given repo and prints the result
func CreateMilestone(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")
return fmt.Errorf("title is required")
}
mile, _, err := login.Client().CreateMilestone(repoOwner, repoName, gitea.CreateMilestoneOption{

View File

@@ -9,7 +9,6 @@ import (
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/config"
local_git "code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/workaround"
"github.com/go-git/go-git/v5"
git_config "github.com/go-git/go-git/v5/config"
@@ -29,9 +28,6 @@ func PullCheckout(
if err != nil {
return fmt.Errorf("couldn't fetch PR: %s", err)
}
if err := workaround.FixPullHeadSha(client, pr); err != nil {
return err
}
// FIXME: should use ctx.LocalRepo..?
localRepo, err := local_git.RepoForWorkdir()
@@ -89,7 +85,7 @@ func doPRFetch(
if err != nil {
return "", err
}
auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback)
auth, err := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKey, callback)
if err != nil {
return "", err
}

View File

@@ -8,7 +8,6 @@ import (
"code.gitea.io/tea/modules/config"
local_git "code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/workaround"
"code.gitea.io/sdk/gitea"
git_config "github.com/go-git/go-git/v5/config"
@@ -33,9 +32,6 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign
if err != nil {
return err
}
if err := workaround.FixPullHeadSha(client, pr); err != nil {
return err
}
if pr.State == gitea.StateOpen {
return fmt.Errorf("PR is still open, won't delete branches")
@@ -43,7 +39,7 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign
// if remote head branch is already deleted, pr.Head.Ref points to "pulls/<idx>/head"
remoteBranch := pr.Head.Ref
remoteDeleted := remoteBranch == fmt.Sprintf("refs/pull/%d/head", pr.Index)
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)
@@ -66,9 +62,9 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign
}
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", remoteBranch)
}
return fmt.Errorf(`Remote branch %s not found in local repo.
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)
@@ -96,15 +92,15 @@ call me again with the --ignore-sha flag`, remoteBranch)
if !remoteDeleted && pr.Head.Repository.Permissions.Push {
fmt.Printf("Deleting remote branch %s\n", remoteBranch)
url, err := r.TeaRemoteURL(branch.Remote)
if err != nil {
return err
url, urlErr := r.TeaRemoteURL(branch.Remote)
if urlErr != nil {
return urlErr
}
auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback)
if err != nil {
return err
auth, authErr := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKey, callback)
if authErr != nil {
return authErr
}
err = r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth)
return r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth)
}
return err
return nil
}

View File

@@ -153,3 +153,68 @@ func GetDefaultPRTitle(header string) string {
return title
}
// CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result
func CreateAgitFlowPull(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(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.SSHKey, callback)
if err != nil {
return err
}
return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth)
}

79
modules/task/pull_edit.go Normal file
View File

@@ -0,0 +1,79 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/context"
)
// EditPull edits a pull request and returns the updated pull request.
func EditPull(ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.PullRequest, error) {
if client == nil {
client = ctx.Login.Client()
}
addLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, opts.AddLabels)
if err != nil {
return nil, err
}
rmLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, opts.RemoveLabels)
if err != nil {
return nil, err
}
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(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)
}
}
if len(opts.AddAssignees) != 0 {
prOpts.Assignees = opts.AddAssignees
prOptsDirty = true
}
if err := ApplyLabelChanges(client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
return nil, err
}
if err := ApplyReviewerChanges(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.EditPullRequest(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.GetPullRequest(ctx.Owner, ctx.Repo, opts.Index)
if err != nil {
return nil, fmt.Errorf("could not get pull request: %s", err)
}
}
return pr, nil
}

View File

@@ -18,7 +18,7 @@ func PullMerge(login *config.Login, repoOwner, repoName string, index int64, opt
return err
}
if !success {
return fmt.Errorf("Failed to merge PR. Is it still open?")
return fmt.Errorf("failed to merge PR, is it still open?")
}
return nil
}

View File

@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/context"
)
// ListPullReviewComments lists all review comments across all reviews for a PR
func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) {
c := ctx.Login.Client()
var reviews []*gitea.PullReview
for page := 1; ; {
page_reviews, resp, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
reviews = append(reviews, page_reviews...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
var allComments []*gitea.PullReviewComment
for _, review := range reviews {
comments, _, err := c.ListPullReviewComments(ctx.Owner, ctx.Repo, idx, review.ID)
if err != nil {
return nil, err
}
allComments = append(allComments, comments...)
}
return allComments, nil
}
// ResolvePullReviewComment resolves a review comment
func ResolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()
_, err := c.ResolvePullReviewComment(ctx.Owner, ctx.Repo, commentID)
if err != nil {
return err
}
fmt.Printf("Comment %d resolved\n", commentID)
return nil
}
// UnresolvePullReviewComment unresolves a review comment
func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()
_, err := c.UnresolvePullReviewComment(ctx.Owner, ctx.Repo, commentID)
if err != nil {
return err
}
fmt.Printf("Comment %d unresolved\n", commentID)
return nil
}

View File

@@ -35,12 +35,12 @@ func RepoClone(
return nil, err
}
auth, err := local_git.GetAuthForURL(originURL, login.Token, login.SSHKey, callback)
auth, err := local_git.GetAuthForURL(originURL, login.GetAccessToken(), login.SSHKey, callback)
if err != nil {
return nil, err
}
// default path behaviour as native git
// default path behavior as native git
if path == "" {
path = repoName
}

View File

@@ -4,20 +4,26 @@
package theme
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/compat"
)
var giteaTheme = func() *huh.Theme {
theme := huh.ThemeCharm()
// TeaTheme implements the huh.Theme interface with tea-cli styling.
type TeaTheme struct{}
title := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
// Theme implements the huh.Theme interface.
func (t TeaTheme) Theme(isDark bool) *huh.Styles {
theme := huh.ThemeCharm(isDark)
title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")}
theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true)
theme.Blurred = theme.Focused
return theme
}()
// GetTheme returns the Gitea theme for Huh
func GetTheme() *huh.Theme {
return giteaTheme
}
// GetTheme returns the default theme for huh prompts.
func GetTheme() TeaTheme {
var t TeaTheme
return t
}

87
modules/utils/input.go Normal file
View File

@@ -0,0 +1,87 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package utils
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"syscall"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
// ReadValueOptions contains options for reading a value from various sources
type ReadValueOptions struct {
// ResourceName is the name of the resource (e.g., "secret", "variable")
ResourceName string
// PromptMsg is the message to display when prompting interactively
PromptMsg string
// Hidden determines if the input should be hidden (for secrets/passwords)
Hidden bool
// AllowEmpty determines if empty values are allowed
AllowEmpty bool
}
// ReadValue reads a value from various sources in the following priority order:
// 1. From a file specified by --file flag
// 2. From stdin if --stdin flag is set
// 3. From command arguments (second argument)
// 4. Interactive prompt
func ReadValue(cmd *cli.Command, opts ReadValueOptions) (string, error) {
var value string
// 1. Read from file
if filePath := cmd.String("file"); filePath != "" {
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
value = strings.TrimSpace(string(content))
} else if cmd.Bool("stdin") {
// 2. Read from stdin
content, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
value = strings.TrimSpace(string(content))
} else if cmd.Args().Len() >= 2 {
// 3. Use provided argument
value = cmd.Args().Get(1)
} else {
// 4. Interactive prompt
if opts.PromptMsg == "" {
opts.PromptMsg = fmt.Sprintf("Enter %s value", opts.ResourceName)
}
fmt.Printf("%s: ", opts.PromptMsg)
if opts.Hidden {
// Hidden input for secrets/passwords
byteValue, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return "", fmt.Errorf("failed to read %s value: %w", opts.ResourceName, err)
}
fmt.Println() // Add newline after hidden input
value = string(byteValue)
} else {
// Regular visible input - read entire line including spaces
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read %s value: %w", opts.ResourceName, err)
}
value = strings.TrimSpace(input)
}
}
// Validate non-empty if required
if !opts.AllowEmpty && value == "" {
return "", fmt.Errorf("%s value cannot be empty", opts.ResourceName)
}
return value, nil
}

View File

@@ -9,6 +9,7 @@ import (
"os/user"
"path/filepath"
"strings"
"syscall"
)
// PathExists returns whether the given file or directory exists or not
@@ -38,18 +39,19 @@ func exists(path string, expectDir bool) (bool, error) {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
} else if err.(*os.PathError).Err.Error() == "not a directory" {
// some middle segment of path is a file, cannot traverse
// FIXME: catches error on linux; go does not provide a way to catch this properly..
}
var pathErr *os.PathError
if errors.As(err, &pathErr) && errors.Is(pathErr.Err, syscall.ENOTDIR) {
// a middle segment of path is a file, cannot traverse
return false, nil
}
return false, err
}
isDir := f.IsDir()
if isDir && !expectDir {
return false, errors.New("A directory with the same name exists")
return false, errors.New("a directory with the same name exists")
} else if !isDir && expectDir {
return false, errors.New("A file with the same name exists")
return false, errors.New("a file with the same name exists")
}
return true, nil
}
@@ -62,9 +64,9 @@ func AbsPathWithExpansion(p string) (string, error) {
}
if p == "~" {
return u.HomeDir, nil
} else if strings.HasPrefix(p, "~/") {
return filepath.Join(u.HomeDir, p[2:]), nil
} else {
return filepath.Abs(p)
}
if strings.HasPrefix(p, "~/") {
return filepath.Join(u.HomeDir, p[2:]), nil
}
return filepath.Abs(p)
}

View File

@@ -14,6 +14,9 @@ func ValidateAuthenticationMethod(
token string,
user string,
passwd string,
sshAgent bool,
sshKey string,
sshCertPrincipal string,
) (*url.URL, error) {
// Normalize URL
serverURL, err := NormalizeURL(giteaURL)
@@ -21,14 +24,15 @@ func ValidateAuthenticationMethod(
return nil, fmt.Errorf("unable to parse URL: %s", err)
}
// .. if we have enough information to authenticate
if len(token) == 0 && (len(user)+len(passwd)) == 0 {
return nil, fmt.Errorf("no token set")
} else if len(user) != 0 && len(passwd) == 0 {
return nil, fmt.Errorf("no password set")
} else if len(user) == 0 && len(passwd) != 0 {
return nil, fmt.Errorf("no user set")
if !sshAgent && sshCertPrincipal == "" && sshKey == "" {
// .. if we have enough information to authenticate
if len(token) == 0 && (len(user)+len(passwd)) == 0 {
return nil, fmt.Errorf("no token set")
} else if len(user) != 0 && len(passwd) == 0 {
return nil, fmt.Errorf("no password set")
} else if len(user) == 0 && len(passwd) != 0 {
return nil, fmt.Errorf("no user set")
}
}
return serverURL, nil
}

View File

@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package version
import (
"fmt"
"runtime"
"strings"
)
// Version holds the current tea version.
// This is set at build time via ldflags.
// If the Version is moved to another package or name changed,
// build flags in .goreleaser.yaml or Makefile need to be updated accordingly.
var Version = "development"
// Tags holds the build tags used
var Tags = ""
// SDK holds the sdk version from go.mod
var SDK = ""
// Format returns a human-readable version string including
// go version, build tags, and SDK version when available.
func Format() string {
s := fmt.Sprintf("Version: %s\tgolang: %s",
bold(Version),
strings.ReplaceAll(runtime.Version(), "go", ""))
if len(Tags) != 0 {
s += fmt.Sprintf("\tbuilt with: %s", strings.ReplaceAll(Tags, " ", ", "))
}
if len(SDK) != 0 {
s += fmt.Sprintf("\tgo-sdk: %s", SDK)
}
return s
}
func bold(t string) string {
return fmt.Sprintf("\033[1m%s\033[0m", t)
}

View File

@@ -1,29 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workaround
import (
"fmt"
"code.gitea.io/sdk/gitea"
)
// FixPullHeadSha is a workaround for https://github.com/go-gitea/gitea/issues/12675
// When no head sha is available, this is because the branch got deleted in the base repo.
// pr.Head.Ref points in this case not to the head repo branch name, but the base repo ref,
// which stays available to resolve the commit sha.
func FixPullHeadSha(client *gitea.Client, pr *gitea.PullRequest) error {
owner := pr.Base.Repository.Owner.UserName
repo := pr.Base.Repository.Name
if pr.Head != nil && pr.Head.Sha == "" {
refs, _, err := client.GetRepoRefs(owner, repo, pr.Head.Ref)
if err != nil {
return err
} else if len(refs) == 0 {
return fmt.Errorf("unable to resolve PR ref '%s'", pr.Head.Ref)
}
pr.Head.Sha = refs[0].Object.SHA
}
return nil
}