Merge branch 'update-with-local-changes'
# Conflicts: # cmd/actions.go # cmd/actions/runs.go # cmd/issues/dependencies.go # docs/CLI.md # modules/api/client.go
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
modules/config/credstore.go
Normal file
65
modules/config/credstore.go
Normal 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
85
modules/config/lock.go
Normal 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
196
modules/config/lock_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
13
modules/config/testtools.go
Normal file
13
modules/config/testtools.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
51
modules/context/context_login.go
Normal file
51
modules/context/context_login.go
Normal 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,
|
||||
}
|
||||
}
|
||||
52
modules/context/context_prompt_test.go
Normal file
52
modules/context/context_prompt_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
modules/context/context_remote.go
Normal file
58
modules/context/context_remote.go
Normal 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
|
||||
}
|
||||
83
modules/context/context_repo.go
Normal file
83
modules/context/context_repo.go
Normal 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
|
||||
}
|
||||
41
modules/context/context_require.go
Normal file
41
modules/context/context_require.go
Normal 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
|
||||
}
|
||||
113
modules/context/context_require_test.go
Normal file
113
modules/context/context_require_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
70
modules/filelock/filelock.go
Normal file
70
modules/filelock/filelock.go
Normal 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
|
||||
}
|
||||
92
modules/filelock/filelock_test.go
Normal file
92
modules/filelock/filelock_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
39
modules/filelock/filelock_unix.go
Normal file
39
modules/filelock/filelock_unix.go
Normal 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)
|
||||
}
|
||||
48
modules/filelock/filelock_windows.go
Normal file
48
modules/filelock/filelock_windows.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
38
modules/httputil/httputil.go
Normal file
38
modules/httputil/httputil.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:").
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
215
modules/print/actions_runs.go
Normal file
215
modules/print/actions_runs.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
256
modules/print/actions_runs_test.go
Normal file
256
modules/print/actions_runs_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
33
modules/print/branch_test.go
Normal file
33
modules/print/branch_test.go
Normal 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/")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
73
modules/print/pull_review_comment.go
Normal file
73
modules/print/pull_review_comment.go
Normal 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
189
modules/print/pull_test.go
Normal 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'")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
33
modules/print/repo_test.go
Normal file
33
modules/print/repo_test.go
Normal 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"])
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
57
modules/task/login_create_test.go
Normal file
57
modules/task/login_create_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
79
modules/task/pull_edit.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
68
modules/task/pull_review_comment.go
Normal file
68
modules/task/pull_review_comment.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
87
modules/utils/input.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
44
modules/version/version.go
Normal file
44
modules/version/version.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user