mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)
- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated. - **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes. ## Breaking changes This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement. ## Issues fixes #1118 fixes #1116 fixes #1115 fixes #1094 fixes #1091 fixes #1073 fixes #1052 fixes #1032 fixes #1023 fixes #966 fixes #908 fixes #833 fixes #831 fixes #817 fixes #804 fixes #779 fixes #770 fixes #737 fixes #697 fixes #726 fixes #677 fixes #672 fixes #633 fixes #632 fixes #560 fixes #556 fixes #523 fixes #516 fixes #510 fixes #504 fixes #500 fixes #482 fixes #406 fixes #403 fixes #373 fixes #351 fixes #344 fixes #326 fixes #325 fixes #323 fixes #246 fixes #240 fixes #232 fixes #197 fixes #172 fixes #141 fixes #69 fixes #1110 fixes #569 fixes #480 fixes #666 fixes #670 fixes #457 fixes #643 fixes #498 fixes #490 fixes #1069 fixes #834 fixes #602 fixes #328 fixes #417 fixes #571 fixes #1029 fixes #489 fixes #1050 fixes #162 fixes #430 fixes #1087 fixes #683 Reviewed-on: https://gitea.com/gitea/tea/pulls/1126 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
12e415a7a7
commit
f02245449b
456 files changed
+18892
-34015
No files matched your search
@@ -1,104 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/httputil"
|
||||
)
|
||||
|
||||
// Client provides direct HTTP access to Gitea API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client from a Login config
|
||||
func NewClient(login *config.Login) *Client {
|
||||
// Refresh OAuth token if expired or near expiry
|
||||
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
log.Printf("Warning: failed to refresh OAuth token: %v", err)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: login.Insecure}),
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimSuffix(login.URL, "/"),
|
||||
token: login.GetAccessToken(),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Do executes an HTTP request with authentication headers
|
||||
func (c *Client) Do(method, endpoint string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
||||
// Build the full URL
|
||||
reqURL, err := c.buildURL(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, reqURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set authentication header
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
|
||||
// Set default content type for requests with body
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Apply custom headers (can override defaults)
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
// buildURL constructs the full URL from an endpoint
|
||||
func (c *Client) buildURL(endpoint string) (string, error) {
|
||||
// If endpoint is already a full URL, validate it matches the login's host
|
||||
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||
endpointURL, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
baseURL, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
if endpointURL.Host != baseURL.Host {
|
||||
return "", fmt.Errorf("URL host %q does not match login host %q (token would be sent to wrong server)", endpointURL.Host, baseURL.Host)
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
// Ensure endpoint starts with /
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
|
||||
// Auto-prefix /api/v1/ if not present
|
||||
if !strings.HasPrefix(endpoint, "/api/") {
|
||||
endpoint = "/api/v1" + endpoint
|
||||
}
|
||||
|
||||
return c.baseURL + endpoint, nil
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/httputil"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
|
||||
"github.com/skratchdot/open-golang/open"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Constants for OAuth2 PKCE flow
|
||||
const (
|
||||
// default scopes to request
|
||||
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
|
||||
|
||||
// length of code verifier
|
||||
codeVerifierLength = 64
|
||||
|
||||
// timeout for oauth server response
|
||||
authTimeout = 60 * time.Second
|
||||
|
||||
// local server settings to receive the callback
|
||||
redirectPort = 0
|
||||
redirectHost = "127.0.0.1"
|
||||
)
|
||||
|
||||
// OAuthOptions contains options for the OAuth login flow
|
||||
type OAuthOptions struct {
|
||||
Name string
|
||||
URL string
|
||||
Insecure bool
|
||||
ClientID string
|
||||
RedirectURL string
|
||||
Port int
|
||||
}
|
||||
|
||||
// OAuthLogin performs an OAuth2 PKCE login flow to authorize the CLI
|
||||
func OAuthLogin(ctx context.Context, name, giteaURL string) error {
|
||||
return OAuthLoginWithOptions(ctx, name, giteaURL, false)
|
||||
}
|
||||
|
||||
// OAuthLoginWithOptions performs an OAuth2 PKCE login flow with additional options
|
||||
func OAuthLoginWithOptions(ctx context.Context, name, giteaURL string, insecure bool) error {
|
||||
opts := OAuthOptions{
|
||||
Name: name,
|
||||
URL: giteaURL,
|
||||
Insecure: insecure,
|
||||
ClientID: config.DefaultClientID,
|
||||
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
|
||||
Port: redirectPort,
|
||||
}
|
||||
return OAuthLoginWithFullOptions(ctx, opts)
|
||||
}
|
||||
|
||||
// OAuthLoginWithFullOptions performs an OAuth2 PKCE login flow with full options control
|
||||
func OAuthLoginWithFullOptions(ctx context.Context, opts OAuthOptions) error {
|
||||
serverURL, token, err := performBrowserOAuthFlow(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return createLoginFromToken(ctx, opts.Name, serverURL, token, opts.Insecure)
|
||||
}
|
||||
|
||||
// performBrowserOAuthFlow performs the browser-based OAuth2 PKCE flow and returns the token.
|
||||
// This is the shared implementation used by both new logins and re-authentication.
|
||||
func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL string, token *oauth2.Token, err error) {
|
||||
// Normalize URL
|
||||
normalizedURL, err := utils.NormalizeURL(opts.URL)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to parse URL: %s", err)
|
||||
}
|
||||
serverURL = normalizedURL.String()
|
||||
|
||||
// Set defaults if needed
|
||||
if opts.ClientID == "" {
|
||||
opts.ClientID = config.DefaultClientID
|
||||
}
|
||||
|
||||
// If the redirect URL is specified, parse it to extract port if needed
|
||||
if opts.RedirectURL != "" {
|
||||
parsedURL, err := url.Parse(opts.RedirectURL)
|
||||
if err == nil && parsedURL.Port() != "" {
|
||||
port, err := strconv.Atoi(parsedURL.Port())
|
||||
if err == nil {
|
||||
opts.Port = port
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no redirect URL, ensure we have a port and then set the default redirect URL
|
||||
if opts.Port == 0 {
|
||||
opts.Port = redirectPort
|
||||
}
|
||||
opts.RedirectURL = fmt.Sprintf("http://%s:%d", redirectHost, opts.Port)
|
||||
}
|
||||
|
||||
// Double check that port is set
|
||||
if opts.Port == 0 {
|
||||
opts.Port = redirectPort
|
||||
}
|
||||
|
||||
// Generate code verifier (random string)
|
||||
codeVerifier, err := generateCodeVerifier(codeVerifierLength)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to generate code verifier: %s", err)
|
||||
}
|
||||
|
||||
// Generate code challenge (SHA256 hash of code verifier)
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
// Set up the OAuth2 config
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(opts.Insecure))
|
||||
|
||||
// Configure the OAuth2 endpoints
|
||||
authURL := fmt.Sprintf("%s/login/oauth/authorize", normalizedURL)
|
||||
tokenURL := fmt.Sprintf("%s/login/oauth/access_token", normalizedURL)
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: opts.ClientID,
|
||||
ClientSecret: "", // No client secret for PKCE
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: authURL,
|
||||
TokenURL: tokenURL,
|
||||
},
|
||||
RedirectURL: opts.RedirectURL,
|
||||
Scopes: strings.Split(defaultScopes, ","),
|
||||
}
|
||||
|
||||
// Set up PKCE extension options
|
||||
authCodeOpts := []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
}
|
||||
|
||||
// Generate state parameter to protect against CSRF
|
||||
state, err := generateCodeVerifier(32)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to generate state: %s", err)
|
||||
}
|
||||
|
||||
// Get the authorization URL
|
||||
authCodeURL := oauth2Config.AuthCodeURL(state, authCodeOpts...)
|
||||
|
||||
// Start a local server to receive the callback. When opts.Port == 0,
|
||||
// this resolves opts.RedirectURL to the actual listener port; mirror
|
||||
// that into oauth2Config so Exchange sends the same redirect_uri the
|
||||
// authorize step advertised (RFC 6749 §4.1.3).
|
||||
code, receivedState, err := startLocalServerAndOpenBrowser(authCodeURL, state, &opts)
|
||||
oauth2Config.RedirectURL = opts.RedirectURL
|
||||
if err != nil {
|
||||
// Check for redirect URI errors
|
||||
if strings.Contains(err.Error(), "no authorization code") ||
|
||||
strings.Contains(err.Error(), "redirect_uri") ||
|
||||
strings.Contains(err.Error(), "redirect") {
|
||||
fmt.Println("\nError: Redirect URL not registered in Gitea")
|
||||
fmt.Println("\nTo fix this, you need to register the redirect URL in Gitea:")
|
||||
fmt.Printf("1. Go to your Gitea instance: %s\n", normalizedURL)
|
||||
fmt.Println("2. Sign in and go to Settings > Applications")
|
||||
fmt.Println("3. Register a new OAuth2 application with:")
|
||||
fmt.Printf(" - Application Name: tea-cli (or any name)\n")
|
||||
fmt.Printf(" - Redirect URI: %s\n", opts.RedirectURL)
|
||||
fmt.Println("4. Copy the Client ID and try again with:")
|
||||
fmt.Printf(" tea login add --oauth --client-id YOUR_CLIENT_ID --redirect-url %s\n", opts.RedirectURL)
|
||||
fmt.Println("\nAlternatively, you can use a token-based login: tea login add")
|
||||
}
|
||||
return "", nil, fmt.Errorf("authorization failed: %s", err)
|
||||
}
|
||||
|
||||
// Verify state to prevent CSRF attacks
|
||||
if state != receivedState {
|
||||
return "", nil, fmt.Errorf("state mismatch, possible CSRF attack")
|
||||
}
|
||||
|
||||
// Exchange authorization code for token
|
||||
token, err = oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("token exchange failed: %s", err)
|
||||
}
|
||||
|
||||
return serverURL, token, nil
|
||||
}
|
||||
|
||||
// createHTTPClient creates an HTTP client with optional insecure setting
|
||||
func createHTTPClient(insecure bool) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: insecure}),
|
||||
}
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically random string for PKCE
|
||||
func generateCodeVerifier(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a code challenge from the code verifier using SHA256
|
||||
func generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// startLocalServerAndOpenBrowser starts a local HTTP server to receive the
|
||||
// OAuth callback and opens the browser to the authorization URL. If
|
||||
// opts.Port is 0, the listener picks a free port and opts.RedirectURL is
|
||||
// rewritten in place with that port.
|
||||
func startLocalServerAndOpenBrowser(authURL, expectedState string, opts *OAuthOptions) (string, string, error) {
|
||||
// Channel to receive the authorization code
|
||||
codeChan := make(chan string, 1)
|
||||
stateChan := make(chan string, 1)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
// Parse the redirect URL to get the path
|
||||
parsedURL, err := url.Parse(opts.RedirectURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid redirect URL: %s", err)
|
||||
}
|
||||
|
||||
// Path to listen for in the callback
|
||||
callbackPath := parsedURL.Path
|
||||
if callbackPath == "" {
|
||||
callbackPath = "/"
|
||||
}
|
||||
|
||||
// Get the hostname from the redirect URL
|
||||
hostname := parsedURL.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = redirectHost
|
||||
}
|
||||
|
||||
// Ensure we have a valid port
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
if parsedPort := parsedURL.Port(); parsedPort != "" {
|
||||
port, _ = strconv.Atoi(parsedPort)
|
||||
}
|
||||
}
|
||||
|
||||
// Server address with port (may be dynamic if port=0)
|
||||
serverAddr := fmt.Sprintf("%s:%d", hostname, port)
|
||||
|
||||
// Start local server
|
||||
server := &http.Server{
|
||||
Addr: serverAddr,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only process the callback path
|
||||
if r.URL.Path != callbackPath {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract code and state from URL parameters
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
error := r.URL.Query().Get("error")
|
||||
errorDesc := r.URL.Query().Get("error_description")
|
||||
|
||||
if error != "" {
|
||||
errMsg := error
|
||||
if errorDesc != "" {
|
||||
errMsg += ": " + errorDesc
|
||||
}
|
||||
errChan <- fmt.Errorf("authorization error: %s", errMsg)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Error: %s", errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
errChan <- fmt.Errorf("no authorization code received")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Error: No authorization code received")
|
||||
return
|
||||
}
|
||||
|
||||
// Send success response to browser
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "Authorization successful! You can close this window and return to the CLI.")
|
||||
|
||||
// Send code to channel
|
||||
codeChan <- code
|
||||
stateChan <- state
|
||||
}),
|
||||
}
|
||||
|
||||
// Listener for getting the actual port when using port 0
|
||||
listener, err := net.Listen("tcp", serverAddr)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to start local server: %s", err)
|
||||
}
|
||||
|
||||
// Get the actual port if we used port 0
|
||||
if port == 0 {
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
port = addr.Port
|
||||
|
||||
// Update redirect URL with actual port
|
||||
parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port)
|
||||
opts.RedirectURL = parsedURL.String()
|
||||
|
||||
// Update the auth URL with the new redirect URL
|
||||
authURLParsed, err := url.Parse(authURL)
|
||||
if err == nil {
|
||||
query := authURLParsed.Query()
|
||||
query.Set("redirect_uri", opts.RedirectURL)
|
||||
authURLParsed.RawQuery = query.Encode()
|
||||
authURL = authURLParsed.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
fmt.Printf("Starting local server on %s:%d...\n", hostname, port)
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait a bit for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Open browser
|
||||
fmt.Println("Opening browser for authorization...")
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
fmt.Println("Failed to open browser: ", err)
|
||||
}
|
||||
|
||||
// Wait for code, error, or timeout
|
||||
select {
|
||||
case code := <-codeChan:
|
||||
state := <-stateChan
|
||||
// Shut down server
|
||||
go server.Close()
|
||||
return code, state, nil
|
||||
case err := <-errChan:
|
||||
go server.Close()
|
||||
return "", "", err
|
||||
case <-time.After(authTimeout):
|
||||
go server.Close()
|
||||
return "", "", fmt.Errorf("authentication timed out after %s", authTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser opens the default browser to the specified URL.
|
||||
// It is a variable to allow tests to inject a fake browser.
|
||||
var openBrowser = func(url string) error {
|
||||
fmt.Printf("Please authorize the application by visiting this URL in your browser:\n%s\n", url)
|
||||
|
||||
// Don't wait for the opener to exit, so a browser that holds the
|
||||
// foreground can't block the wait for the callback.
|
||||
return open.Start(url)
|
||||
}
|
||||
|
||||
// createLoginFromToken creates a login entry using the obtained access token
|
||||
func createLoginFromToken(ctx context.Context, name, serverURL string, token *oauth2.Token, insecure bool) error {
|
||||
if name == "" {
|
||||
var err error
|
||||
name, err = task.GenerateLoginName(serverURL, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create login object with OAuth auth method
|
||||
login := config.Login{
|
||||
Name: name,
|
||||
URL: serverURL,
|
||||
Token: token.AccessToken, // temporarily set for Client() validation
|
||||
AuthMethod: config.AuthMethodOAuth,
|
||||
Insecure: insecure,
|
||||
VersionCheck: true,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Validate token by getting user info
|
||||
client := login.Client()
|
||||
u, _, err := client.Users.GetMyUserInfo(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate token: %s", err)
|
||||
}
|
||||
|
||||
// Clear token from YAML fields (will be stored in credstore)
|
||||
login.Token = ""
|
||||
|
||||
// Set user info
|
||||
login.User = u.UserName
|
||||
|
||||
// Get SSH host
|
||||
parsedURL, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
login.SSHHost = parsedURL.Host
|
||||
|
||||
// Save tokens and add login to config
|
||||
if err := config.AddOAuthLogin(&login, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken manually renews an access token using the refresh token.
|
||||
// This is used by the "tea login oauth-refresh" command for explicit token refresh.
|
||||
// For automatic threshold-based refresh, use login.Client() which handles it internally.
|
||||
func RefreshAccessToken(login *config.Login) error {
|
||||
return login.RefreshOAuthToken()
|
||||
}
|
||||
|
||||
// ReauthenticateLogin performs a full browser-based OAuth flow to get new tokens
|
||||
// for an existing login. This is used when the refresh token is expired or invalid.
|
||||
func ReauthenticateLogin(ctx context.Context, login *config.Login) error {
|
||||
opts := OAuthOptions{
|
||||
Name: login.Name,
|
||||
URL: login.URL,
|
||||
Insecure: login.Insecure,
|
||||
ClientID: config.DefaultClientID,
|
||||
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
|
||||
Port: redirectPort,
|
||||
}
|
||||
|
||||
_, token, err := performBrowserOAuthFlow(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if login.IsOAuth() {
|
||||
return config.SaveOAuthTokenFromOAuth2(login.Name, token, login)
|
||||
}
|
||||
|
||||
// Legacy path for non-OAuth logins
|
||||
login.Token = token.AccessToken
|
||||
if token.RefreshToken != "" {
|
||||
login.RefreshToken = token.RefreshToken
|
||||
}
|
||||
if !token.Expiry.IsZero() {
|
||||
login.TokenExpiry = token.Expiry.Unix()
|
||||
}
|
||||
return config.SaveLoginTokens(login)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Regression test for the redirect_uri propagation bug: with --redirect-url
|
||||
// omitted, the callback listener picks a free port and opts.RedirectURL is
|
||||
// rewritten in place. The OAuth2 config must see that rewritten URL so the
|
||||
// token exchange sends the same redirect_uri the authorize step advertised
|
||||
// (RFC 6749 §4.1.3).
|
||||
func TestPerformBrowserOAuthFlow_RedirectURIMatchesAcrossAuthorizeAndExchange(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
authorizeRedirectURI string
|
||||
exchangeRedirectURI string
|
||||
)
|
||||
|
||||
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login/oauth/authorize":
|
||||
mu.Lock()
|
||||
authorizeRedirectURI = r.URL.Query().Get("redirect_uri")
|
||||
mu.Unlock()
|
||||
cb, err := url.Parse(r.URL.Query().Get("redirect_uri"))
|
||||
require.NoError(t, err)
|
||||
q := cb.Query()
|
||||
q.Set("code", "test-code")
|
||||
q.Set("state", r.URL.Query().Get("state"))
|
||||
cb.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, cb.String(), http.StatusFound)
|
||||
|
||||
case "/login/oauth/access_token":
|
||||
require.NoError(t, r.ParseForm())
|
||||
mu.Lock()
|
||||
exchangeRedirectURI = r.PostForm.Get("redirect_uri")
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(idp.Close)
|
||||
|
||||
// Replace the real browser opener with a goroutine that fetches the
|
||||
// authorize URL, just like a real browser would.
|
||||
origOpenBrowser := openBrowser
|
||||
t.Cleanup(func() { openBrowser = origOpenBrowser })
|
||||
openBrowser = func(authURL string) error {
|
||||
go func() {
|
||||
resp, err := http.Get(authURL)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Leave RedirectURL empty so tea picks a random port.
|
||||
_, token, err := performBrowserOAuthFlow(context.Background(), OAuthOptions{
|
||||
URL: idp.URL,
|
||||
ClientID: "test-client-id",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token", token.AccessToken)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.NotEmpty(t, authorizeRedirectURI)
|
||||
require.NotEmpty(t, exchangeRedirectURI)
|
||||
assert.Equal(t, authorizeRedirectURI, exchangeRedirectURI,
|
||||
"redirect_uri must match between authorize and token exchange (RFC 6749 §4.1.3)")
|
||||
}
|
||||
|
||||
// Regression test for the browser opener hang: xdg-open does not exit until
|
||||
// the browser it launched does, and the callback is only consumed after
|
||||
// openBrowser returns. Waiting on the opener hangs the CLI even though the
|
||||
// user authenticated successfully.
|
||||
func TestOpenBrowser_DoesNotWaitForOpener(t *testing.T) {
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
t.Skip("xdg-open is not the opener on this platform")
|
||||
}
|
||||
|
||||
const (
|
||||
fakeOpenerSleepTime = 10 * time.Second
|
||||
openBrowserTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// A stand-in xdg-open that holds the foreground the way a browser it had
|
||||
// to launch would.
|
||||
dir := t.TempDir()
|
||||
opener := filepath.Join(dir, "xdg-open")
|
||||
script := fmt.Sprintf("#!/bin/sh\nexec sleep %d\n", int(fakeOpenerSleepTime.Seconds()))
|
||||
require.NoError(t, os.WriteFile(opener, []byte(script), 0o755))
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- openBrowser("http://127.0.0.1:1/") }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(openBrowserTimeout):
|
||||
t.Fatal("openBrowser blocked on the opener; the callback would never be consumed")
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/tea/modules/utils"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// FlagDefaults defines all flags that can be overridden with a default value
|
||||
// via the config file
|
||||
type FlagDefaults struct {
|
||||
// Prefer a specific git remote to use for selecting a repository on gitea,
|
||||
// instead of relying on the remote associated with main/master/trunk branch.
|
||||
// The --remote flag still has precedence over this value.
|
||||
Remote string `yaml:"remote"`
|
||||
}
|
||||
|
||||
// Preferences that are stored in and read from the config file
|
||||
type Preferences struct {
|
||||
// Prefer using an external text editor over inline multiline prompts
|
||||
Editor bool `yaml:"editor"`
|
||||
FlagDefaults FlagDefaults `yaml:"flag_defaults"`
|
||||
}
|
||||
|
||||
// LocalConfig represents local configurations
|
||||
type LocalConfig struct {
|
||||
Logins []Login `yaml:"logins"`
|
||||
Prefs Preferences `yaml:"preferences"`
|
||||
}
|
||||
|
||||
var (
|
||||
// config contain if loaded local tea config
|
||||
config LocalConfig
|
||||
loadConfigOnce sync.Once
|
||||
configPathMu sync.Mutex
|
||||
configPathTestOverride string
|
||||
)
|
||||
|
||||
// GetConfigPath return path to tea config file
|
||||
func GetConfigPath() string {
|
||||
configPathMu.Lock()
|
||||
override := configPathTestOverride
|
||||
configPathMu.Unlock()
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
configFilePath, err := xdg.ConfigFile("tea/config.yml")
|
||||
|
||||
var exists bool
|
||||
if err != nil {
|
||||
exists = false
|
||||
} else {
|
||||
exists, _ = utils.PathExists(configFilePath)
|
||||
}
|
||||
|
||||
// fallback to old config if no new one exists
|
||||
if !exists {
|
||||
file := filepath.Join(xdg.Home, ".tea", "tea.yml")
|
||||
exists, _ = utils.PathExists(file)
|
||||
if exists {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unable to get or create config file")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return configFilePath
|
||||
}
|
||||
|
||||
// SetConfigPathForTesting overrides the config path used by helpers in tests.
|
||||
func SetConfigPathForTesting(path string) {
|
||||
configPathMu.Lock()
|
||||
configPathTestOverride = path
|
||||
configPathMu.Unlock()
|
||||
}
|
||||
|
||||
// GetPreferences returns preferences based on the config file
|
||||
func GetPreferences() Preferences {
|
||||
_ = loadConfig()
|
||||
return config.Prefs
|
||||
}
|
||||
|
||||
// loadConfig load config from file
|
||||
func loadConfig() (err error) {
|
||||
loadConfigOnce.Do(func() {
|
||||
ymlPath := GetConfigPath()
|
||||
exist, _ := utils.FileExist(ymlPath)
|
||||
if exist {
|
||||
bs, readErr := os.ReadFile(ymlPath)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("failed to read config file %s: %w", ymlPath, readErr)
|
||||
return
|
||||
}
|
||||
|
||||
if unmarshalErr := yaml.Unmarshal(bs, &config); unmarshalErr != nil {
|
||||
err = fmt.Errorf("failed to parse config file %s: %w", ymlPath, unmarshalErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// reloadConfigFromDisk re-reads the config file from disk, bypassing the sync.Once.
|
||||
// This is used after acquiring a lock to ensure we have the latest config state.
|
||||
// The caller must hold the config lock.
|
||||
func reloadConfigFromDisk() error {
|
||||
ymlPath := GetConfigPath()
|
||||
exist, _ := utils.FileExist(ymlPath)
|
||||
if !exist {
|
||||
// No config file yet, start with empty config
|
||||
config = LocalConfig{}
|
||||
return nil
|
||||
}
|
||||
|
||||
bs, err := os.ReadFile(ymlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read config file %s: %w", ymlPath, err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(bs, &config); err != nil {
|
||||
return fmt.Errorf("failed to parse config file %s: %w", ymlPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetConfigForTesting replaces the in-memory config and marks it as loaded.
|
||||
// This allows tests to inject config without relying on file-based loading.
|
||||
func SetConfigForTesting(cfg LocalConfig) {
|
||||
loadConfigOnce.Do(func() {}) // ensure sync.Once is spent
|
||||
config = cfg
|
||||
}
|
||||
|
||||
// saveConfigUnsafe saves config to file without acquiring a lock.
|
||||
// Caller must hold the config lock.
|
||||
func saveConfigUnsafe() error {
|
||||
ymlPath := GetConfigPath()
|
||||
bs, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(ymlPath, bs, 0o600)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/go-signet/sdk-go/credstore"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var (
|
||||
tokenStore *credstore.SecureStore[credstore.Token]
|
||||
tokenStoreOnce sync.Once
|
||||
|
||||
saveOAuthTokenToStore = func(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
|
||||
return getTokenStore().Save(loginName, credstore.Token{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
ClientID: loginName,
|
||||
})
|
||||
}
|
||||
deleteOAuthTokenFromStore = func(loginName string) error {
|
||||
return getTokenStore().Delete(loginName)
|
||||
}
|
||||
)
|
||||
|
||||
func getTokenStore() *credstore.SecureStore[credstore.Token] {
|
||||
tokenStoreOnce.Do(func() {
|
||||
filePath := filepath.Join(xdg.ConfigHome, "tea", "credentials.json")
|
||||
tokenStore = credstore.DefaultTokenSecureStore("tea-cli", filePath)
|
||||
})
|
||||
return tokenStore
|
||||
}
|
||||
|
||||
// LoadOAuthToken loads OAuth tokens from the secure store.
|
||||
func LoadOAuthToken(loginName string) (*credstore.Token, error) {
|
||||
tok, err := getTokenStore().Load(loginName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// SaveOAuthToken saves OAuth tokens to the secure store.
|
||||
func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
|
||||
return saveOAuthTokenToStore(loginName, accessToken, refreshToken, expiresAt)
|
||||
}
|
||||
|
||||
// DeleteOAuthToken removes tokens from the secure store.
|
||||
func DeleteOAuthToken(loginName string) error {
|
||||
return deleteOAuthTokenFromStore(loginName)
|
||||
}
|
||||
|
||||
// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to
|
||||
// the existing login's values for empty refresh token or zero expiry.
|
||||
func SaveOAuthTokenFromOAuth2(loginName string, token *oauth2.Token, login *Login) error {
|
||||
refreshToken := token.RefreshToken
|
||||
if refreshToken == "" {
|
||||
refreshToken = login.GetRefreshToken()
|
||||
}
|
||||
expiry := token.Expiry
|
||||
if expiry.IsZero() {
|
||||
expiry = login.GetTokenExpiry()
|
||||
}
|
||||
return SaveOAuthToken(loginName, token.AccessToken, refreshToken, expiry)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/filelock"
|
||||
)
|
||||
|
||||
const (
|
||||
// LockTimeout is the default timeout for acquiring the config file lock.
|
||||
LockTimeout = 5 * time.Second
|
||||
|
||||
// mutexPollInterval is how often to retry acquiring the in-process mutex.
|
||||
mutexPollInterval = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
// configMutex protects in-process concurrent access to the config.
|
||||
var configMutex sync.Mutex
|
||||
|
||||
// acquireConfigLock acquires both the in-process mutex and a file lock.
|
||||
// Returns an unlock function that must be called to release both locks.
|
||||
// The timeout applies to acquiring the file lock; the mutex acquisition
|
||||
// uses the same timeout via a TryLock loop.
|
||||
func acquireConfigLock(lockPath string, timeout time.Duration) (unlock func() error, err error) {
|
||||
// Try to acquire mutex with timeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if configMutex.TryLock() {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("timeout waiting for config mutex")
|
||||
}
|
||||
time.Sleep(mutexPollInterval)
|
||||
}
|
||||
|
||||
// Mutex acquired, now try file lock
|
||||
remaining := max(time.Until(deadline), 0)
|
||||
locker := filelock.New(lockPath, remaining)
|
||||
|
||||
fileUnlock, err := locker.Acquire()
|
||||
if err != nil {
|
||||
configMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return unlock function
|
||||
return func() error {
|
||||
unlockErr := fileUnlock()
|
||||
configMutex.Unlock()
|
||||
return unlockErr
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getConfigLockPath returns the path to the lock file for the config.
|
||||
func getConfigLockPath() string {
|
||||
return GetConfigPath() + ".lock"
|
||||
}
|
||||
|
||||
// withConfigLock executes the given function while holding the config lock.
|
||||
// It acquires the lock, reloads the config from disk, executes fn, and releases the lock.
|
||||
func withConfigLock(fn func() error) (retErr error) {
|
||||
lockPath := getConfigLockPath()
|
||||
unlock, err := acquireConfigLock(lockPath, LockTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire config lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if unlockErr := unlock(); unlockErr != nil && retErr == nil {
|
||||
retErr = fmt.Errorf("failed to release config lock: %w", unlockErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Reload config from disk to get latest state
|
||||
if err := reloadConfigFromDisk(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigLock_BasicLockUnlock(t *testing.T) {
|
||||
// Create a temp directory for test
|
||||
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
lockPath := filepath.Join(tmpDir, "config.yml.lock")
|
||||
|
||||
// Should be able to acquire lock
|
||||
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire lock: %v", err)
|
||||
}
|
||||
|
||||
// Should be able to release lock
|
||||
err = unlock()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to release lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLock_MutexProtection(t *testing.T) {
|
||||
// Create a temp directory for test
|
||||
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
lockPath := filepath.Join(tmpDir, "config.yml.lock")
|
||||
|
||||
// Acquire lock
|
||||
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to acquire lock: %v", err)
|
||||
}
|
||||
|
||||
// Try to acquire again from same process - should block/timeout due to mutex
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
_, err := acquireConfigLock(lockPath, 100*time.Millisecond)
|
||||
done <- (err != nil) // Should timeout/fail
|
||||
}()
|
||||
|
||||
select {
|
||||
case failed := <-done:
|
||||
if !failed {
|
||||
t.Error("second lock acquisition should have failed due to mutex")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("test timed out")
|
||||
}
|
||||
|
||||
if err := unlock(); err != nil {
|
||||
t.Errorf("failed to unlock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func useTempConfigPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
SetConfigPathForTesting(configPath)
|
||||
t.Cleanup(func() {
|
||||
SetConfigPathForTesting("")
|
||||
})
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
func TestReloadConfigFromDisk(t *testing.T) {
|
||||
configPath := useTempConfigPath(t)
|
||||
|
||||
// Save original config state
|
||||
originalConfig := config
|
||||
|
||||
config = LocalConfig{Logins: []Login{{Name: "stale"}}}
|
||||
if err := os.WriteFile(configPath, []byte("logins:\n - name: test\n"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write temp config: %v", err)
|
||||
}
|
||||
|
||||
err := reloadConfigFromDisk()
|
||||
if err != nil {
|
||||
t.Fatalf("reloadConfigFromDisk returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(config.Logins) != 1 || config.Logins[0].Name != "test" {
|
||||
t.Fatalf("expected config to reload test login, got %+v", config.Logins)
|
||||
}
|
||||
|
||||
// Restore original config
|
||||
config = originalConfig
|
||||
}
|
||||
|
||||
func TestWithConfigLock(t *testing.T) {
|
||||
useTempConfigPath(t)
|
||||
|
||||
executed := false
|
||||
err := withConfigLock(func() error {
|
||||
executed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("withConfigLock returned error: %v", err)
|
||||
}
|
||||
if !executed {
|
||||
t.Error("function was not executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithConfigLock_PropagatesError(t *testing.T) {
|
||||
useTempConfigPath(t)
|
||||
|
||||
expectedErr := fmt.Errorf("test error")
|
||||
err := withConfigLock(func() error {
|
||||
return expectedErr
|
||||
})
|
||||
|
||||
if err != expectedErr {
|
||||
t.Errorf("expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoubleCheckedLocking_SimulatedRefresh(t *testing.T) {
|
||||
useTempConfigPath(t)
|
||||
|
||||
// This test simulates the double-checked locking pattern
|
||||
// by having multiple goroutines try to "refresh" simultaneously
|
||||
|
||||
var (
|
||||
refreshCount int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// Simulate what RefreshOAuthToken does with double-check
|
||||
simulatedRefresh := func(tokenExpiry *int64) error {
|
||||
// First check (without lock)
|
||||
if *tokenExpiry > time.Now().Unix() {
|
||||
return nil // Token still valid
|
||||
}
|
||||
|
||||
return withConfigLock(func() error {
|
||||
// Double-check after acquiring lock
|
||||
if *tokenExpiry > time.Now().Unix() {
|
||||
return nil // Another goroutine refreshed it
|
||||
}
|
||||
|
||||
// Simulate refresh
|
||||
mu.Lock()
|
||||
refreshCount++
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(50 * time.Millisecond) // Simulate API call
|
||||
*tokenExpiry = time.Now().Add(1 * time.Hour).Unix()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Start with expired token
|
||||
tokenExpiry := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Launch multiple goroutines trying to refresh
|
||||
var wg sync.WaitGroup
|
||||
for range 5 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := simulatedRefresh(&tokenExpiry); err != nil {
|
||||
t.Errorf("refresh failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should only have refreshed once due to double-checked locking
|
||||
if refreshCount != 1 {
|
||||
t.Errorf("expected 1 refresh, got %d", refreshCount)
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/debug"
|
||||
"gitea.dev/tea/modules/httputil"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens.
|
||||
// This is used by config.Login.Client() for automatic token refresh.
|
||||
const TokenRefreshThreshold = 5 * time.Minute
|
||||
|
||||
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances
|
||||
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
|
||||
|
||||
// AuthMethodOAuth marks a login as using OAuth with secure credential storage.
|
||||
const AuthMethodOAuth = "oauth"
|
||||
|
||||
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
|
||||
type Login struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token,omitempty"`
|
||||
Default bool `yaml:"default"`
|
||||
SSHHost string `yaml:"ssh_host"`
|
||||
// optional path to the private key
|
||||
SSHKey string `yaml:"ssh_key"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
SSHCertPrincipal string `yaml:"ssh_certificate_principal"`
|
||||
SSHAgent bool `yaml:"ssh_agent"`
|
||||
SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"`
|
||||
SSHPassphrase string `yaml:"-"`
|
||||
VersionCheck bool `yaml:"version_check"`
|
||||
// User is username from gitea
|
||||
User string `yaml:"user"`
|
||||
// Created is auto created unix timestamp
|
||||
Created int64 `yaml:"created"`
|
||||
// AuthMethod indicates the authentication method ("oauth" for OAuth with credstore)
|
||||
AuthMethod string `yaml:"auth_method,omitempty"`
|
||||
// RefreshToken is used to renew the access token when it expires
|
||||
RefreshToken string `yaml:"refresh_token,omitempty"`
|
||||
// TokenExpiry is when the token expires (unix timestamp)
|
||||
TokenExpiry int64 `yaml:"token_expiry,omitempty"`
|
||||
}
|
||||
|
||||
// IsOAuth returns true if this login uses OAuth with secure credential storage.
|
||||
func (l *Login) IsOAuth() bool {
|
||||
return l.AuthMethod == AuthMethodOAuth
|
||||
}
|
||||
|
||||
// loadOAuthToken loads the OAuth token from credstore, returning nil if
|
||||
// this is not an OAuth login or if the load fails (caller should fallback).
|
||||
func (l *Login) loadOAuthToken() *OAuthToken {
|
||||
if !l.IsOAuth() {
|
||||
return nil
|
||||
}
|
||||
tok, err := LoadOAuthToken(l.Name)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &OAuthToken{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
ExpiresAt: tok.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthToken holds the token fields loaded from credstore.
|
||||
type OAuthToken struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// GetAccessToken returns the effective access token.
|
||||
// For OAuth logins, reads from credstore. For others, returns l.Token directly.
|
||||
func (l *Login) GetAccessToken() string {
|
||||
if tok := l.loadOAuthToken(); tok != nil {
|
||||
return tok.AccessToken
|
||||
}
|
||||
return l.Token
|
||||
}
|
||||
|
||||
// GetRefreshToken returns the refresh token.
|
||||
func (l *Login) GetRefreshToken() string {
|
||||
if tok := l.loadOAuthToken(); tok != nil {
|
||||
return tok.RefreshToken
|
||||
}
|
||||
return l.RefreshToken
|
||||
}
|
||||
|
||||
// GetTokenExpiry returns the token expiry time.
|
||||
func (l *Login) GetTokenExpiry() time.Time {
|
||||
if tok := l.loadOAuthToken(); tok != nil {
|
||||
return tok.ExpiresAt
|
||||
}
|
||||
return time.Unix(l.TokenExpiry, 0)
|
||||
}
|
||||
|
||||
// GetLogins return all login available by config
|
||||
func GetLogins() ([]Login, error) {
|
||||
if err := loadConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.Logins, nil
|
||||
}
|
||||
|
||||
// GetDefaultLogin return the default login
|
||||
func GetDefaultLogin() (*Login, error) {
|
||||
if err := loadConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(config.Logins) == 0 {
|
||||
return nil, errors.New("no available login")
|
||||
}
|
||||
for _, l := range config.Logins {
|
||||
if l.Default {
|
||||
return &l, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &config.Logins[0], nil
|
||||
}
|
||||
|
||||
// SetDefaultLogin set the default login by name (case insensitive)
|
||||
func SetDefaultLogin(name string) error {
|
||||
return withConfigLock(func() error {
|
||||
loginExist := false
|
||||
for i := range config.Logins {
|
||||
config.Logins[i].Default = false
|
||||
if strings.EqualFold(config.Logins[i].Name, name) {
|
||||
config.Logins[i].Default = true
|
||||
loginExist = true
|
||||
}
|
||||
}
|
||||
|
||||
if !loginExist {
|
||||
return fmt.Errorf("login '%s' not found", name)
|
||||
}
|
||||
|
||||
return saveConfigUnsafe()
|
||||
})
|
||||
}
|
||||
|
||||
// GetLoginByName get login by name (case insensitive)
|
||||
func GetLoginByName(name string) (*Login, error) {
|
||||
if err := loadConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range config.Logins {
|
||||
if strings.EqualFold(config.Logins[i].Name, name) {
|
||||
return &config.Logins[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetLoginByToken get login by token
|
||||
func GetLoginByToken(token string) (*Login, error) {
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := loadConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, l := range config.Logins {
|
||||
if l.Token == token {
|
||||
return &l, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetLoginByHost finds a login by its server URL
|
||||
func GetLoginByHost(host string) (*Login, error) {
|
||||
logins, err := GetLoginsByHost(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(logins) > 0 {
|
||||
return logins[0], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetLoginsByHost returns all logins matching a host
|
||||
func GetLoginsByHost(host string) ([]*Login, error) {
|
||||
if err := loadConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matches []*Login
|
||||
for i := range config.Logins {
|
||||
loginURL, err := url.Parse(config.Logins[i].URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if loginURL.Host == host {
|
||||
matches = append(matches, &config.Logins[i])
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// DeleteLogin delete a login by name from config
|
||||
func DeleteLogin(name string) error {
|
||||
return withConfigLock(func() error {
|
||||
idx := -1
|
||||
for i, l := range config.Logins {
|
||||
if strings.EqualFold(l.Name, name) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return fmt.Errorf("can not delete login '%s', does not exist", name)
|
||||
}
|
||||
|
||||
isOAuth := config.Logins[idx].IsOAuth()
|
||||
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
|
||||
|
||||
// Clean up credstore tokens for OAuth logins
|
||||
if isOAuth {
|
||||
_ = DeleteOAuthToken(name)
|
||||
}
|
||||
|
||||
return saveConfigUnsafe()
|
||||
})
|
||||
}
|
||||
|
||||
// AddLogin save a login to config
|
||||
func AddLogin(login *Login) error {
|
||||
return withConfigLock(func() error {
|
||||
// Check for duplicate login names
|
||||
for _, existing := range config.Logins {
|
||||
if strings.EqualFold(existing.Name, login.Name) {
|
||||
return fmt.Errorf("login name '%s' already exists", login.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// save login to global var
|
||||
config.Logins = append(config.Logins, *login)
|
||||
|
||||
// save login to config file
|
||||
return saveConfigUnsafe()
|
||||
})
|
||||
}
|
||||
|
||||
// AddOAuthLogin saves the OAuth token and login profile as one operation.
|
||||
// The profile is only written after secure token storage succeeds.
|
||||
func AddOAuthLogin(login *Login, accessToken, refreshToken string, expiresAt time.Time) error {
|
||||
return withConfigLock(func() error {
|
||||
// Check for duplicate login names before touching credential storage.
|
||||
for _, existing := range config.Logins {
|
||||
if strings.EqualFold(existing.Name, login.Name) {
|
||||
return fmt.Errorf("login name '%s' already exists", login.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if err := SaveOAuthToken(login.Name, accessToken, refreshToken, expiresAt); err != nil {
|
||||
return fmt.Errorf("failed to save token to secure store: %w", err)
|
||||
}
|
||||
|
||||
config.Logins = append(config.Logins, *login)
|
||||
if err := saveConfigUnsafe(); err != nil {
|
||||
config.Logins = config.Logins[:len(config.Logins)-1]
|
||||
if deleteErr := DeleteOAuthToken(login.Name); deleteErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("failed to clean up OAuth token after config save failure: %w", deleteErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SaveLoginTokens updates the token fields for an existing login.
|
||||
// This is used after browser-based re-authentication to save new tokens.
|
||||
func SaveLoginTokens(login *Login) error {
|
||||
if login.IsOAuth() {
|
||||
return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry())
|
||||
}
|
||||
return withConfigLock(func() error {
|
||||
for i, l := range config.Logins {
|
||||
if strings.EqualFold(l.Name, login.Name) {
|
||||
config.Logins[i].Token = login.Token
|
||||
config.Logins[i].RefreshToken = login.RefreshToken
|
||||
config.Logins[i].TokenExpiry = login.TokenExpiry
|
||||
return saveConfigUnsafe()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("login %s not found", login.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
|
||||
// Returns nil without doing anything if no refresh is needed.
|
||||
func (l *Login) RefreshOAuthTokenIfNeeded() error {
|
||||
// Load once to avoid multiple credstore reads
|
||||
if tok := l.loadOAuthToken(); tok != nil {
|
||||
if tok.RefreshToken == "" || tok.ExpiresAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
if time.Now().Add(TokenRefreshThreshold).After(tok.ExpiresAt) {
|
||||
return l.RefreshOAuthToken()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Non-OAuth path: use YAML fields
|
||||
if l.RefreshToken == "" || l.TokenExpiry == 0 {
|
||||
return nil
|
||||
}
|
||||
if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) {
|
||||
return l.RefreshOAuthToken()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshOAuthToken refreshes the OAuth access token using the refresh token.
|
||||
// It updates the login with new token information and saves it to config.
|
||||
// Uses double-checked locking to avoid unnecessary refresh calls when multiple
|
||||
// processes race to refresh the same token.
|
||||
func (l *Login) RefreshOAuthToken() error {
|
||||
if l.GetRefreshToken() == "" {
|
||||
return fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
return withConfigLock(func() error {
|
||||
// Double-check: after acquiring lock, re-read config and check if
|
||||
// another process already refreshed the token
|
||||
for i, login := range config.Logins {
|
||||
if login.Name == l.Name {
|
||||
// Check if token was refreshed by another process
|
||||
currentExpiry := login.GetTokenExpiry()
|
||||
ourExpiry := l.GetTokenExpiry()
|
||||
if currentExpiry != ourExpiry && !currentExpiry.IsZero() {
|
||||
if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) {
|
||||
// Token was refreshed by another process, update our copy
|
||||
if !login.IsOAuth() {
|
||||
l.Token = login.Token
|
||||
l.RefreshToken = login.RefreshToken
|
||||
l.TokenExpiry = login.TokenExpiry
|
||||
}
|
||||
// For OAuth logins, credstore already has the latest tokens
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Still need to refresh - proceed with OAuth call
|
||||
newToken, err := doOAuthRefresh(context.Background(), l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if l.IsOAuth() {
|
||||
// Save tokens to credstore; no YAML changes needed
|
||||
return SaveOAuthTokenFromOAuth2(l.Name, newToken, l)
|
||||
}
|
||||
|
||||
// Update login with new token information (legacy path)
|
||||
l.Token = newToken.AccessToken
|
||||
if newToken.RefreshToken != "" {
|
||||
l.RefreshToken = newToken.RefreshToken
|
||||
}
|
||||
if !newToken.Expiry.IsZero() {
|
||||
l.TokenExpiry = newToken.Expiry.Unix()
|
||||
}
|
||||
config.Logins[i] = *l
|
||||
return saveConfigUnsafe()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("login %s not found", l.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// doOAuthRefresh performs the actual OAuth token refresh API call.
|
||||
func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
|
||||
// Build current token from credstore (single load) or YAML fields
|
||||
var accessToken, refreshToken string
|
||||
var expiry time.Time
|
||||
if tok := l.loadOAuthToken(); tok != nil {
|
||||
accessToken = tok.AccessToken
|
||||
refreshToken = tok.RefreshToken
|
||||
expiry = tok.ExpiresAt
|
||||
} else {
|
||||
accessToken = l.Token
|
||||
refreshToken = l.RefreshToken
|
||||
expiry = time.Unix(l.TokenExpiry, 0)
|
||||
}
|
||||
currentToken := &oauth2.Token{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
Expiry: expiry,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
|
||||
}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: DefaultClientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
|
||||
},
|
||||
}
|
||||
|
||||
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
return newToken, nil
|
||||
}
|
||||
|
||||
// Client returns a client to operate Gitea API. You may provide additional modifiers
|
||||
// for the client like gitea.SetBasicAuth() for customization
|
||||
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
||||
// Refresh OAuth token if expired or near expiry
|
||||
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return l.ClientWithoutRefresh(options...)
|
||||
}
|
||||
|
||||
// ClientWithoutRefresh returns a client to operate the Gitea API without
|
||||
// attempting an automatic OAuth token refresh. Commands that need to handle
|
||||
// token refresh errors themselves (such as 'tea login status') should use this
|
||||
// instead of Client, which prints to stderr and exits on refresh failure.
|
||||
func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Client {
|
||||
// Configure transport-level timeouts so a stalled or unresponsive server
|
||||
// fails fast instead of hanging forever. These bound connection setup and
|
||||
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
|
||||
// large attachment uploads) are unaffected.
|
||||
httpClient := &http.Client{
|
||||
Transport: httputil.WrapTransport(nil),
|
||||
}
|
||||
if l.Insecure {
|
||||
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
|
||||
|
||||
httpClient = &http.Client{
|
||||
Jar: cookieJar,
|
||||
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
|
||||
}
|
||||
}
|
||||
|
||||
// versioncheck must be prepended in options to make sure we don't hit any version checks in the sdk
|
||||
if !l.VersionCheck {
|
||||
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
|
||||
}
|
||||
|
||||
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
|
||||
// transport already sets: this is the SDK's own guarantee, so the UA survives
|
||||
// even if the client is ever given a transport that didn't come from WrapTransport.
|
||||
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
|
||||
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
|
||||
if debug.IsDebug() {
|
||||
options = append(options, gitea.SetDebugMode())
|
||||
}
|
||||
|
||||
if l.SSHCertPrincipal != "" {
|
||||
if err := l.askForSSHPassphrase(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKeyPath(), l.SSHPassphrase))
|
||||
}
|
||||
|
||||
if l.SSHKeyFingerprint != "" {
|
||||
if err := l.askForSSHPassphrase(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKeyPath(), l.SSHPassphrase))
|
||||
}
|
||||
|
||||
client, err := gitea.NewClient(l.URL, options...)
|
||||
if err != nil {
|
||||
var versionError *gitea.ErrUnknownVersion
|
||||
if !errors.As(err, &versionError) {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create Gitea client: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func (l *Login) askForSSHPassphrase() error {
|
||||
if ok, err := utils.IsKeyEncrypted(l.SSHKeyPath()); ok && err == nil && l.SSHPassphrase == "" {
|
||||
return huh.NewInput().
|
||||
Title("ssh-key is encrypted please enter the passphrase: ").
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&l.SSHPassphrase).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHKeyPath returns the on-disk SSH key to use, or an empty string when the
|
||||
// login is configured to authenticate through a running ssh-agent.
|
||||
func (l *Login) SSHKeyPath() string {
|
||||
if l.SSHAgent {
|
||||
return ""
|
||||
}
|
||||
return l.SSHKey
|
||||
}
|
||||
|
||||
// GetSSHHost returns SSH host name
|
||||
func (l *Login) GetSSHHost() string {
|
||||
if l.SSHHost != "" {
|
||||
return l.SSHHost
|
||||
}
|
||||
|
||||
u, err := url.Parse(l.URL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return u.Host
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoginClientWithSSHPubkeyDoesNotDeadlockOnFirstRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sshKeyPath, fingerprint := writeTestSSHKey(t)
|
||||
|
||||
var versionRequests atomic.Int32
|
||||
var issueRequests atomic.Int32
|
||||
var signedVersionRequests atomic.Int32
|
||||
var signedIssueRequests atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/version":
|
||||
versionRequests.Add(1)
|
||||
if r.Header.Get("Signature") != "" {
|
||||
signedVersionRequests.Add(1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"version":"1.26.4"}`))
|
||||
case "/api/v1/repos/gitea/tea/issues":
|
||||
issueRequests.Add(1)
|
||||
if r.Header.Get("Signature") != "" {
|
||||
signedIssueRequests.Add(1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
login := &Login{
|
||||
Name: "ssh-login",
|
||||
URL: server.URL,
|
||||
SSHKey: sshKeyPath,
|
||||
SSHKeyFingerprint: fingerprint,
|
||||
VersionCheck: true,
|
||||
}
|
||||
|
||||
type result struct {
|
||||
issues []*gitea.Issue
|
||||
err error
|
||||
}
|
||||
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
issues, _, err := login.Client().Issues.ListRepoIssues(context.Background(), "gitea", "tea", gitea.ListIssueOption{})
|
||||
done <- result{issues: issues, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
require.NoError(t, res.err)
|
||||
assert.Empty(t, res.issues)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ListRepoIssues deadlocked while bootstrapping the server version for SSH-signed requests")
|
||||
}
|
||||
|
||||
assert.EqualValues(t, 1, versionRequests.Load())
|
||||
assert.EqualValues(t, 0, signedVersionRequests.Load())
|
||||
assert.EqualValues(t, 1, issueRequests.Load())
|
||||
assert.EqualValues(t, 1, signedIssueRequests.Load())
|
||||
}
|
||||
|
||||
func TestLoginSSHKeyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
login Login
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "disk key",
|
||||
login: Login{SSHKey: "/tmp/id_ed25519"},
|
||||
want: "/tmp/id_ed25519",
|
||||
},
|
||||
{
|
||||
name: "ssh agent ignores disk key",
|
||||
login: Login{SSHKey: "/tmp/id_ed25519", SSHAgent: true},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "ssh agent without disk key",
|
||||
login: Login{SSHAgent: true},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tt.want, tt.login.SSHKeyPath())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSSHKey(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
|
||||
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
|
||||
require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600))
|
||||
|
||||
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// AcquireConfigLockForTesting exposes the internal lock helper to integration tests.
|
||||
func AcquireConfigLockForTesting(lockPath string, timeout time.Duration) (func() error, error) {
|
||||
return acquireConfigLock(lockPath, timeout)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/git"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var errNotAGiteaRepo = errors.New("no Gitea login found; you might want to specify --repo (and --login) to work outside of a repository")
|
||||
|
||||
// ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt.
|
||||
var ErrCommandCanceled = errors.New("command canceled")
|
||||
|
||||
// TeaContext contains all context derived during command initialization and wraps cli.Context
|
||||
type TeaContext struct {
|
||||
*cli.Command
|
||||
Login *config.Login // config data & client for selected login
|
||||
RepoSlug string // <owner>/<repo>, optional
|
||||
Owner string // repo owner as derived from context or provided in flag, optional
|
||||
Repo string // repo name as derived from context or provided in flag, optional
|
||||
Org string // organization name, optional
|
||||
IsGlobal bool // true if operating on global level
|
||||
Output string // value of output flag
|
||||
LocalRepo *git.TeaRepo // is set if flags specified a local repo via --repo, or if $PWD is a git repo
|
||||
}
|
||||
|
||||
// InitOptions controls which optional sources InitCommand may inspect.
|
||||
type InitOptions struct {
|
||||
// SkipLocalRepo avoids probing the current directory for a git repository.
|
||||
SkipLocalRepo bool
|
||||
}
|
||||
|
||||
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
|
||||
// after ensuring a remote repo is present in the context.
|
||||
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
|
||||
if err := ctx.Ensure(CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(ctx.Login.URL, "/") + "/" + ctx.Owner + "/" + ctx.Repo, nil
|
||||
}
|
||||
|
||||
// IsInteractiveMode returns true if the command is running in interactive mode
|
||||
// (no flags provided and stdout is a terminal)
|
||||
func (ctx *TeaContext) IsInteractiveMode() bool {
|
||||
return ctx.Command.NumFlags() == 0
|
||||
}
|
||||
|
||||
func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool {
|
||||
return login != nil && !login.Default && canPrompt
|
||||
}
|
||||
|
||||
// InitCommand resolves the application context, and returns the active login, and if
|
||||
// available the repo slug. It does this by reading the config file for logins, parsing
|
||||
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
|
||||
// command flags. If a local git repo can't be found, repo slug values are unset.
|
||||
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||
return InitCommandWithOptions(cmd, InitOptions{})
|
||||
}
|
||||
|
||||
// InitCommandWithOptions resolves the application context like InitCommand, with
|
||||
// optional controls for commands that do not need repository context.
|
||||
func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, error) {
|
||||
// these flags are used as overrides to the context detection via local git repo
|
||||
repoFlag := cmd.String("repo")
|
||||
loginFlag := cmd.String("login")
|
||||
remoteFlag := cmd.String("remote")
|
||||
orgFlag := cmd.String("org")
|
||||
globalFlag := cmd.Bool("global")
|
||||
|
||||
var (
|
||||
c TeaContext
|
||||
err error
|
||||
repoPath string // empty means PWD
|
||||
repoFlagPathExists bool
|
||||
)
|
||||
|
||||
// check if repoFlag can be interpreted as path to local repo.
|
||||
if len(repoFlag) != 0 && !opts.SkipLocalRepo {
|
||||
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if repoFlagPathExists {
|
||||
repoPath = repoFlag
|
||||
} else {
|
||||
c.RepoSlug = repoFlag
|
||||
}
|
||||
} else if len(repoFlag) != 0 {
|
||||
c.RepoSlug = repoFlag
|
||||
}
|
||||
|
||||
if len(remoteFlag) == 0 {
|
||||
remoteFlag = config.GetPreferences().FlagDefaults.Remote
|
||||
}
|
||||
|
||||
// Create env login before repo context detection so it participates in remote URL matching
|
||||
var extraLogins []config.Login
|
||||
envLogin := GetLoginByEnvVar()
|
||||
if envLogin != nil {
|
||||
if _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extraLogins = append(extraLogins, *envLogin)
|
||||
}
|
||||
|
||||
if !opts.SkipLocalRepo {
|
||||
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
||||
// otherwise attempt PWD. if no repo is found, continue with default login
|
||||
if repoPath == "" {
|
||||
if repoPath, err = os.Getwd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var localSlug string
|
||||
if c.LocalRepo, c.Login, localSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
|
||||
if err == errNotAGiteaRepo || err == git.ErrRepositoryNotExists {
|
||||
// we can deal with that, commands needing the optional values use ctx.Ensure()
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if c.RepoSlug == "" && localSlug != "" {
|
||||
c.RepoSlug = localSlug
|
||||
}
|
||||
}
|
||||
|
||||
// If env vars are set, always use the env login (but repo slug was already
|
||||
// resolved by contextFromLocalRepo with the env login in the match list)
|
||||
if envLogin != nil {
|
||||
c.Login = envLogin
|
||||
}
|
||||
|
||||
// override login from flag, or use default login if repo based detection failed
|
||||
if len(loginFlag) != 0 {
|
||||
if c.Login, err = config.GetLoginByName(loginFlag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Login == nil {
|
||||
return nil, fmt.Errorf("login name '%s' does not exist", loginFlag)
|
||||
}
|
||||
} else if c.Login == nil {
|
||||
if c.Login, err = config.GetDefaultLogin(); err != nil {
|
||||
if err.Error() == "No available login" {
|
||||
return nil, fmt.Errorf(`no gitea login configured. To start using tea, first run
|
||||
tea login add
|
||||
and then run your command again`)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only prompt for confirmation if the fallback login is not explicitly set as default
|
||||
canPrompt := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
|
||||
if shouldPromptFallbackLogin(c.Login, canPrompt) {
|
||||
fallback := false
|
||||
if err := huh.NewConfirm().
|
||||
Title(fmt.Sprintf("NOTE: no login matched this repository. Fall back to login '%s'?", c.Login.Name)).
|
||||
Value(&fallback).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return nil, fmt.Errorf("get confirm failed: %w", err)
|
||||
}
|
||||
if !fallback {
|
||||
return nil, ErrCommandCanceled
|
||||
}
|
||||
} else if !c.Login.Default {
|
||||
fmt.Fprintf(os.Stderr, "NOTE: no login matched this repository, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// parse reposlug (owner falling back to login owner if reposlug contains only repo name)
|
||||
c.Owner, c.Repo = utils.GetOwnerAndRepo(c.RepoSlug, c.Login.User)
|
||||
c.Org = orgFlag
|
||||
c.IsGlobal = globalFlag
|
||||
c.Command = cmd
|
||||
c.Output = cmd.String("output")
|
||||
return &c, nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
)
|
||||
|
||||
// GetLoginByEnvVar returns a login based on environment variables, or nil if no login can be created
|
||||
func GetLoginByEnvVar() *config.Login {
|
||||
var token string
|
||||
|
||||
giteaToken := os.Getenv("GITEA_TOKEN")
|
||||
githubToken := os.Getenv("GH_TOKEN")
|
||||
giteaInstanceURL := os.Getenv("GITEA_INSTANCE_URL")
|
||||
giteaInstanceSSHHost := os.Getenv("GITEA_INSTANCE_SSH_HOST")
|
||||
instanceInsecure := os.Getenv("GITEA_INSTANCE_INSECURE")
|
||||
insecure := false
|
||||
if len(instanceInsecure) > 0 {
|
||||
insecure, _ = strconv.ParseBool(instanceInsecure)
|
||||
}
|
||||
|
||||
// if no tokens are set, or no instance url for gitea fail fast
|
||||
if len(giteaInstanceURL) == 0 || (len(giteaToken) == 0 && len(githubToken) == 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
token = giteaToken
|
||||
if len(giteaToken) == 0 {
|
||||
token = githubToken
|
||||
}
|
||||
|
||||
return &config.Login{
|
||||
Name: "GITEA_LOGIN_VIA_ENV",
|
||||
URL: giteaInstanceURL,
|
||||
Token: token,
|
||||
SSHHost: giteaInstanceSSHHost,
|
||||
Insecure: insecure,
|
||||
SSHKey: "",
|
||||
SSHCertPrincipal: "",
|
||||
SSHKeyFingerprint: "",
|
||||
SSHAgent: false,
|
||||
Created: time.Now().Unix(),
|
||||
VersionCheck: false,
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
)
|
||||
|
||||
func TestShouldPromptFallbackLogin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
login *config.Login
|
||||
canPrompt bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no login",
|
||||
login: nil,
|
||||
canPrompt: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "default login",
|
||||
login: &config.Login{Default: true},
|
||||
canPrompt: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "non-default no prompt",
|
||||
login: &config.Login{Default: false},
|
||||
canPrompt: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "non-default prompt",
|
||||
login: &config.Login{Default: false},
|
||||
canPrompt: true,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := shouldPromptFallbackLogin(test.login, test.canPrompt); got != test.expected {
|
||||
t.Fatalf("expected %v, got %v", test.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/debug"
|
||||
"gitea.dev/tea/modules/git"
|
||||
)
|
||||
|
||||
// MatchLogins matches the given remoteURL against the provided logins and returns
|
||||
// the first matching login
|
||||
// remoteURL could be like:
|
||||
//
|
||||
// https://gitea.com/owner/repo.git
|
||||
// http://gitea.com/owner/repo.git
|
||||
// ssh://gitea.com/owner/repo.git
|
||||
// git@gitea.com:owner/repo.git
|
||||
func MatchLogins(remoteURL string, logins []config.Login) (*config.Login, string, error) {
|
||||
for _, l := range logins {
|
||||
debug.Printf("Matching remote URL '%s' against %v login", remoteURL, l)
|
||||
sshHost := l.GetSSHHost()
|
||||
atIdx := strings.Index(remoteURL, "@")
|
||||
colonIdx := strings.Index(remoteURL, ":")
|
||||
if atIdx > 0 && colonIdx > atIdx {
|
||||
domain := remoteURL[atIdx+1 : colonIdx]
|
||||
if domain == sshHost {
|
||||
return &l, strings.TrimSuffix(remoteURL[colonIdx+1:], ".git"), nil
|
||||
}
|
||||
} else {
|
||||
p, err := git.ParseURL(remoteURL)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("git remote URL parse failed: %s", err.Error())
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(p.Scheme, "http") || strings.EqualFold(p.Scheme, "https"):
|
||||
if strings.HasPrefix(remoteURL, l.URL) {
|
||||
ps := strings.Split(p.Path, "/")
|
||||
path := strings.Join(ps[len(ps)-2:], "/")
|
||||
return &l, strings.TrimSuffix(path, ".git"), nil
|
||||
}
|
||||
case strings.EqualFold(p.Scheme, "ssh"):
|
||||
if sshHost == p.Host || sshHost == p.Hostname() {
|
||||
return &l, strings.TrimLeft(p.Path, "/"), nil
|
||||
}
|
||||
default:
|
||||
// unknown scheme
|
||||
return nil, "", fmt.Errorf("git remote URL parse failed: %s", "unknown scheme "+p.Scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", errNotAGiteaRepo
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/debug"
|
||||
"gitea.dev/tea/modules/git"
|
||||
)
|
||||
|
||||
// contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo
|
||||
func contextFromLocalRepo(repoPath, remoteValue string, extraLogins []config.Login) (*git.TeaRepo, *config.Login, string, error) {
|
||||
repo, err := git.RepoFromPath(repoPath)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
gitConfig, err := repo.Config()
|
||||
if err != nil {
|
||||
return repo, nil, "", err
|
||||
}
|
||||
debug.Printf("Get git config %v of %s in repo %s", gitConfig, remoteValue, repoPath)
|
||||
|
||||
if len(gitConfig.Remotes) == 0 {
|
||||
return repo, nil, "", errNotAGiteaRepo
|
||||
}
|
||||
|
||||
// When no preferred value is given, choose a remote to find a
|
||||
// matching login based on its URL.
|
||||
if len(gitConfig.Remotes) > 1 && len(remoteValue) == 0 {
|
||||
// if master branch is present, use it as the default remote
|
||||
mainBranches := []string{"main", "master", "trunk"}
|
||||
for _, b := range mainBranches {
|
||||
masterBranch, ok := gitConfig.Branches[b]
|
||||
if ok {
|
||||
if len(masterBranch.Remote) > 0 {
|
||||
remoteValue = masterBranch.Remote
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// if no branch has matched, default to origin or upstream remote.
|
||||
if len(remoteValue) == 0 {
|
||||
if _, ok := gitConfig.Remotes["upstream"]; ok {
|
||||
remoteValue = "upstream"
|
||||
} else if _, ok := gitConfig.Remotes["origin"]; ok {
|
||||
remoteValue = "origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
// make sure a remote is selected
|
||||
if len(remoteValue) == 0 {
|
||||
for remote := range gitConfig.Remotes {
|
||||
remoteValue = remote
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
remoteConfig, ok := gitConfig.Remotes[remoteValue]
|
||||
if !ok || remoteConfig == nil {
|
||||
return repo, nil, "", fmt.Errorf("remote '%s' not found in this Git repository", remoteValue)
|
||||
}
|
||||
|
||||
debug.Printf("Get remote configurations %v of %s in repo %s", remoteConfig, remoteValue, repoPath)
|
||||
|
||||
logins, err := config.GetLogins()
|
||||
if err != nil {
|
||||
return repo, nil, "", err
|
||||
}
|
||||
// Prepend extra logins (e.g. from env vars) so they are matched first
|
||||
if len(extraLogins) > 0 {
|
||||
logins = append(extraLogins, logins...)
|
||||
}
|
||||
for _, u := range remoteConfig.URLs {
|
||||
if l, p, err := MatchLogins(u, logins); err == nil {
|
||||
return repo, l, p, nil
|
||||
}
|
||||
}
|
||||
|
||||
return repo, nil, "", errNotAGiteaRepo
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Ensure checks if requirements on the context are set.
|
||||
func (ctx *TeaContext) Ensure(req CtxRequirement) error {
|
||||
if req.LocalRepo && ctx.LocalRepo == nil {
|
||||
return errors.New("local repository required: execute from a repo dir, or specify a path with --repo")
|
||||
}
|
||||
|
||||
if req.RemoteRepo && len(ctx.RepoSlug) == 0 {
|
||||
return errors.New("remote repository required: specify id via --repo or execute from a local git repo")
|
||||
}
|
||||
|
||||
if req.Org && len(ctx.Org) == 0 {
|
||||
return errors.New("organization required: specify organization via --org")
|
||||
}
|
||||
|
||||
if req.Global && !ctx.IsGlobal {
|
||||
return errors.New("global scope required: specify --global")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CtxRequirement specifies context needed for operation
|
||||
type CtxRequirement struct {
|
||||
// ensures a local git repo is available & ctx.LocalRepo is set. Implies .RemoteRepo
|
||||
LocalRepo bool
|
||||
// ensures ctx.RepoSlug, .Owner, .Repo are set
|
||||
RemoteRepo bool
|
||||
// ensures ctx.Org is set
|
||||
Org bool
|
||||
// ensures ctx.IsGlobal is true
|
||||
Global bool
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/git"
|
||||
)
|
||||
|
||||
func TestEnsureReturnsRequirementErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx TeaContext
|
||||
req CtxRequirement
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing local repo",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{LocalRepo: true},
|
||||
wantErr: "local repository required",
|
||||
},
|
||||
{
|
||||
name: "missing remote repo",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{RemoteRepo: true},
|
||||
wantErr: "remote repository required",
|
||||
},
|
||||
{
|
||||
name: "missing org",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{Org: true},
|
||||
wantErr: "organization required",
|
||||
},
|
||||
{
|
||||
name: "missing global scope",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{Global: true},
|
||||
wantErr: "global scope required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.ctx.Ensure(tt.req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSucceedsWhenRequirementsMet(t *testing.T) {
|
||||
ctx := TeaContext{
|
||||
LocalRepo: &git.TeaRepo{},
|
||||
RepoSlug: "owner/repo",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Org: "myorg",
|
||||
IsGlobal: true,
|
||||
}
|
||||
err := ctx.Ensure(CtxRequirement{
|
||||
LocalRepo: true,
|
||||
RemoteRepo: true,
|
||||
Org: true,
|
||||
Global: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEnsureSucceedsWithNoRequirements(t *testing.T) {
|
||||
ctx := TeaContext{}
|
||||
err := ctx.Ensure(CtxRequirement{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetRemoteRepoHTMLURL(t *testing.T) {
|
||||
t.Run("requires remote repo", func(t *testing.T) {
|
||||
ctx := &TeaContext{}
|
||||
_, err := ctx.GetRemoteRepoHTMLURL()
|
||||
require.ErrorContains(t, err, "remote repository required")
|
||||
})
|
||||
|
||||
t.Run("returns repo url when context is complete", func(t *testing.T) {
|
||||
ctx := &TeaContext{
|
||||
Login: &config.Login{URL: "https://gitea.example.com"},
|
||||
RepoSlug: "owner/repo",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
url, err := ctx.GetRemoteRepoHTMLURL()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://gitea.example.com/owner/repo", url)
|
||||
})
|
||||
|
||||
t.Run("trims trailing slash from login URL", func(t *testing.T) {
|
||||
ctx := &TeaContext{
|
||||
Login: &config.Login{URL: "https://gitea.example.com/"},
|
||||
RepoSlug: "owner/repo",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
url, err := ctx.GetRemoteRepoHTMLURL()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://gitea.example.com/owner/repo", url)
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
)
|
||||
|
||||
func Test_MatchLogins(t *testing.T) {
|
||||
kases := []struct {
|
||||
remoteURL string
|
||||
logins []config.Login
|
||||
matchedLoginName string
|
||||
expectedRepoPath string
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
remoteURL: "https://gitea.com/owner/repo.git",
|
||||
logins: []config.Login{{Name: "gitea.com", URL: "https://gitea.com"}},
|
||||
matchedLoginName: "gitea.com",
|
||||
expectedRepoPath: "owner/repo",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
remoteURL: "git@gitea.com:owner/repo.git",
|
||||
logins: []config.Login{{Name: "gitea.com", URL: "https://gitea.com"}},
|
||||
matchedLoginName: "gitea.com",
|
||||
expectedRepoPath: "owner/repo",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
remoteURL: "git@custom-ssh.example.com:owner/repo.git",
|
||||
logins: []config.Login{{Name: "env", URL: "https://gitea.example.com", SSHHost: "custom-ssh.example.com"}},
|
||||
matchedLoginName: "env",
|
||||
expectedRepoPath: "owner/repo",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
remoteURL: "https://gitea.example.com/owner/repo.git",
|
||||
logins: []config.Login{
|
||||
{Name: "env", URL: "https://gitea.example.com"},
|
||||
{Name: "config", URL: "https://gitea.example.com"},
|
||||
},
|
||||
matchedLoginName: "env",
|
||||
expectedRepoPath: "owner/repo",
|
||||
hasError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.remoteURL, func(t *testing.T) {
|
||||
login, repoPath, err := MatchLogins(kase.remoteURL, kase.logins)
|
||||
if (err != nil) != kase.hasError {
|
||||
t.Errorf("Expected error: %v, got: %v", kase.hasError, err)
|
||||
}
|
||||
if repoPath != kase.expectedRepoPath {
|
||||
t.Errorf("Expected repo path: %s, got: %s", kase.expectedRepoPath, repoPath)
|
||||
}
|
||||
if !kase.hasError && login.Name != kase.matchedLoginName {
|
||||
t.Errorf("Expected login name: %s, got: %s", kase.matchedLoginName, login.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var debug bool
|
||||
|
||||
// IsDebug returns true if debug mode is enabled
|
||||
func IsDebug() bool {
|
||||
return debug
|
||||
}
|
||||
|
||||
// SetDebug sets the debug mode
|
||||
func SetDebug(on bool) {
|
||||
debug = on
|
||||
}
|
||||
|
||||
// Printf prints debug information if debug mode is enabled
|
||||
func Printf(info string, args ...any) {
|
||||
if debug {
|
||||
fmt.Printf("DEBUG: "+info+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// CliFlag returns the CLI flag for debug mode
|
||||
func CliFlag() cli.Flag {
|
||||
return &cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Aliases: []string{"vvv"},
|
||||
Usage: "Enable debug mode",
|
||||
Value: false,
|
||||
Action: func(ctx context.Context, cmd *cli.Command, v bool) error {
|
||||
SetDebug(v)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package filelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTimeout is the default timeout for acquiring a file lock.
|
||||
DefaultTimeout = 5 * time.Second
|
||||
|
||||
// FileLockPollInterval is how often to retry acquiring the file lock.
|
||||
FileLockPollInterval = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// Locker provides file-based locking with timeout.
|
||||
type Locker struct {
|
||||
path string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// New creates a Locker for the given lock file path.
|
||||
func New(lockPath string, timeout time.Duration) *Locker {
|
||||
return &Locker{
|
||||
path: lockPath,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLock executes fn while holding the lock.
|
||||
func (l *Locker) WithLock(fn func() error) (retErr error) {
|
||||
unlock, err := l.Acquire()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if unlockErr := unlock(); unlockErr != nil && retErr == nil {
|
||||
retErr = fmt.Errorf("failed to release file lock: %w", unlockErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// Acquire acquires the file lock and returns an unlock function.
|
||||
// The caller must call the unlock function to release the lock.
|
||||
func (l *Locker) Acquire() (unlock func() error, err error) {
|
||||
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open lock file: %w", err)
|
||||
}
|
||||
|
||||
if err := lockFile(file, l.timeout); err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("failed to acquire file lock: %w", err)
|
||||
}
|
||||
|
||||
return func() error {
|
||||
unlockErr := unlockFile(file)
|
||||
closeErr := file.Close()
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
return closeErr
|
||||
}, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package filelock
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocker_WithLock(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
lockPath := filepath.Join(tmpDir, "test.lock")
|
||||
|
||||
locker := New(lockPath, DefaultTimeout)
|
||||
|
||||
counter := 0
|
||||
err := locker.WithLock(func() error {
|
||||
counter++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLock failed: %v", err)
|
||||
}
|
||||
if counter != 1 {
|
||||
t.Errorf("Expected counter to be 1, got %d", counter)
|
||||
}
|
||||
|
||||
// Lock file should have been created
|
||||
if _, err := os.Stat(lockPath); os.IsNotExist(err) {
|
||||
t.Error("Lock file should have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocker_Acquire(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
lockPath := filepath.Join(tmpDir, "test.lock")
|
||||
|
||||
locker := New(lockPath, DefaultTimeout)
|
||||
|
||||
unlock, err := locker.Acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire failed: %v", err)
|
||||
}
|
||||
|
||||
// Lock should be held
|
||||
if unlock == nil {
|
||||
t.Fatal("unlock function should not be nil")
|
||||
}
|
||||
|
||||
// Release the lock
|
||||
if err := unlock(); err != nil {
|
||||
t.Fatalf("unlock failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocker_ConcurrentAccess(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
lockPath := filepath.Join(tmpDir, "test.lock")
|
||||
|
||||
locker := New(lockPath, 5*time.Second)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
counter := 0
|
||||
numGoroutines := 10
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := locker.WithLock(func() error {
|
||||
// Read-modify-write to check for race conditions
|
||||
tmp := counter
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
counter = tmp + 1
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("WithLock failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if counter != numGoroutines {
|
||||
t.Errorf("Expected counter to be %d, got %d (possible race condition)", numGoroutines, counter)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build unix
|
||||
|
||||
package filelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// lockFile acquires an exclusive lock on the file using flock.
|
||||
// It polls with non-blocking flock until timeout.
|
||||
func lockFile(file *os.File, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err != syscall.EWOULDBLOCK {
|
||||
return fmt.Errorf("flock failed: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for file lock")
|
||||
}
|
||||
time.Sleep(FileLockPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// unlockFile releases the lock on the file.
|
||||
func unlockFile(file *os.File) error {
|
||||
return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package filelock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// lockFile acquires an exclusive lock on the file using LockFileEx.
|
||||
// It polls with non-blocking LockFileEx until timeout.
|
||||
func lockFile(file *os.File, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
handle := windows.Handle(file.Fd())
|
||||
|
||||
// LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY
|
||||
const flags = windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY
|
||||
|
||||
for {
|
||||
// Lock the first byte (advisory lock)
|
||||
var overlapped windows.Overlapped
|
||||
err := windows.LockFileEx(handle, flags, 0, 1, 0, &overlapped)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err != windows.ERROR_LOCK_VIOLATION {
|
||||
return fmt.Errorf("LockFileEx failed: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for file lock")
|
||||
}
|
||||
time.Sleep(FileLockPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// unlockFile releases the lock on the file.
|
||||
func unlockFile(file *os.File) error {
|
||||
handle := windows.Handle(file.Fd())
|
||||
var overlapped windows.Overlapped
|
||||
return windows.UnlockFileEx(handle, 0, 1, 0, &overlapped)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/modules/utils"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type pwCallback = func(string) (string, error)
|
||||
|
||||
// GetAuthForURL returns backend-agnostic auth settings for git network operations.
|
||||
func GetAuthForURL(remoteURL *url.URL, authToken, keyFile string, passwordCallback pwCallback) (*AuthMethod, error) {
|
||||
switch remoteURL.Scheme {
|
||||
case "http", "https":
|
||||
return &AuthMethod{Scheme: remoteURL.Scheme, Username: authToken}, nil
|
||||
case "ssh":
|
||||
if keyFile == "" {
|
||||
return &AuthMethod{Scheme: remoteURL.Scheme, Username: remoteURL.User.Username()}, nil
|
||||
}
|
||||
expandedKeyFile, err := utils.AbsPathWithExpansion(keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshKey, err := os.ReadFile(expandedKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can not read ssh key '%s'", expandedKeyFile)
|
||||
}
|
||||
|
||||
auth := &AuthMethod{
|
||||
Scheme: remoteURL.Scheme,
|
||||
Username: remoteURL.User.Username(),
|
||||
KeyFile: expandedKeyFile,
|
||||
}
|
||||
if _, err := ssh.ParsePrivateKey(sshKey); err == nil {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
||||
if passwordCallback == nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
pass, err := passwordCallback(expandedKeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ssh.ParsePrivateKeyWithPassphrase(sshKey, []byte(pass)); err == nil {
|
||||
auth.KeyPassphrase = pass
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, err
|
||||
default:
|
||||
return nil, fmt.Errorf("don't know how to handle url scheme %v", remoteURL.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var backendRegistry = struct {
|
||||
sync.RWMutex
|
||||
current string
|
||||
backends map[string]Backend
|
||||
}{
|
||||
backends: make(map[string]Backend),
|
||||
}
|
||||
|
||||
func init() {
|
||||
mustRegisterBackend(cliBackend{})
|
||||
mustUseBackend("cli")
|
||||
}
|
||||
|
||||
// RegisterBackend makes a git backend available for later switching.
|
||||
func RegisterBackend(backend Backend) error {
|
||||
if backend == nil {
|
||||
return fmt.Errorf("git backend is nil")
|
||||
}
|
||||
name := backend.Name()
|
||||
if name == "" {
|
||||
return fmt.Errorf("git backend name is empty")
|
||||
}
|
||||
|
||||
backendRegistry.Lock()
|
||||
defer backendRegistry.Unlock()
|
||||
backendRegistry.backends[name] = backend
|
||||
if backendRegistry.current == "" {
|
||||
backendRegistry.current = name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustRegisterBackend(backend Backend) {
|
||||
if err := RegisterBackend(backend); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// UseBackend switches the active git backend implementation.
|
||||
func UseBackend(name string) error {
|
||||
backendRegistry.Lock()
|
||||
defer backendRegistry.Unlock()
|
||||
if _, ok := backendRegistry.backends[name]; !ok {
|
||||
return fmt.Errorf("git backend %q is not registered", name)
|
||||
}
|
||||
backendRegistry.current = name
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustUseBackend(name string) {
|
||||
if err := UseBackend(name); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentBackendName returns the active backend name.
|
||||
func CurrentBackendName() string {
|
||||
backendRegistry.RLock()
|
||||
defer backendRegistry.RUnlock()
|
||||
return backendRegistry.current
|
||||
}
|
||||
|
||||
// RegisteredBackends returns all registered backend names.
|
||||
func RegisteredBackends() []string {
|
||||
backendRegistry.RLock()
|
||||
defer backendRegistry.RUnlock()
|
||||
out := make([]string, 0, len(backendRegistry.backends))
|
||||
for name := range backendRegistry.backends {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func currentBackend() Backend {
|
||||
backendRegistry.RLock()
|
||||
defer backendRegistry.RUnlock()
|
||||
return backendRegistry.backends[backendRegistry.current]
|
||||
}
|
||||
|
||||
func setBackendForTesting(t testingT, backend Backend) {
|
||||
t.Helper()
|
||||
prev := CurrentBackendName()
|
||||
mustRegisterBackend(backend)
|
||||
mustUseBackend(backend.Name())
|
||||
t.Cleanup(func() { mustUseBackend(prev) })
|
||||
}
|
||||
|
||||
type testingT interface {
|
||||
Cleanup(func())
|
||||
Helper()
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeBackend struct{}
|
||||
|
||||
type fakeRepoBackend struct {
|
||||
workTree string
|
||||
}
|
||||
|
||||
func (fakeBackend) Name() string { return "fake" }
|
||||
|
||||
func (fakeBackend) Open(path string) (RepositoryBackend, error) {
|
||||
return &fakeRepoBackend{workTree: "open:" + path}, nil
|
||||
}
|
||||
|
||||
func (fakeBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
|
||||
return &fakeRepoBackend{workTree: fmt.Sprintf("clone:%s:%s", path, remoteURL)}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepoBackend) WorkTree() string { return r.workTree }
|
||||
func (r *fakeRepoBackend) Config() (*Config, error) {
|
||||
return &Config{Remotes: map[string]*RemoteConfig{}, Branches: map[string]*Branch{}}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepoBackend) Head() (*Reference, error) {
|
||||
return &Reference{name: NewBranchReferenceName("main"), hash: Hash("deadbeef")}, nil
|
||||
}
|
||||
func (r *fakeRepoBackend) AddRemote(name, remoteURL string) error { return nil }
|
||||
func (r *fakeRepoBackend) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepoBackend) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepoBackend) CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRepoBackend) Checkout(ref ReferenceName) error { return nil }
|
||||
func (r *fakeRepoBackend) DeleteLocalBranch(branchName string) error { return nil }
|
||||
func (r *fakeRepoBackend) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepoBackend) ListReferences(prefixes ...string) ([]*Reference, error) { return nil, nil }
|
||||
|
||||
func (r *fakeRepoBackend) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCanSwitchBackends(t *testing.T) {
|
||||
setBackendForTesting(t, fakeBackend{})
|
||||
|
||||
repo, err := RepoFromPath("demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "open:demo", repo.WorkTree())
|
||||
require.Equal(t, "fake", CurrentBackendName())
|
||||
|
||||
cloned, err := Clone("target", "https://example.com/repo.git", nil, 1, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "clone:target:https://example.com/repo.git", cloned.WorkTree())
|
||||
}
|
||||
|
||||
func TestRegisteredBackendsContainsCLI(t *testing.T) {
|
||||
require.Contains(t, RegisteredBackends(), "cli")
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// TeaCreateBranch creates a new branch in the repo, tracking from another branch.
|
||||
func (r TeaRepo) TeaCreateBranch(localBranchName, remoteBranchName, remoteName string) error {
|
||||
return r.backend.CreateTrackingBranch(localBranchName, remoteBranchName, remoteName)
|
||||
}
|
||||
|
||||
// TeaCheckout checks out the given branch in the worktree.
|
||||
func (r TeaRepo) TeaCheckout(ref ReferenceName) error {
|
||||
return r.backend.Checkout(ref)
|
||||
}
|
||||
|
||||
// TeaDeleteLocalBranch removes the given branch locally.
|
||||
func (r TeaRepo) TeaDeleteLocalBranch(branch *Branch) error {
|
||||
return r.backend.DeleteLocalBranch(branch.Name)
|
||||
}
|
||||
|
||||
// TeaDeleteRemoteBranch removes the given branch on the given remote via git protocol.
|
||||
func (r TeaRepo) TeaDeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
|
||||
return r.backend.DeleteRemoteBranch(remoteName, remoteBranch, auth)
|
||||
}
|
||||
|
||||
// TeaFindBranchBySha returns a branch that is at the the given SHA and syncs to the
|
||||
// given remote repo.
|
||||
func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *Branch, err error) {
|
||||
remote, err := r.GetRemote(repoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remote == nil {
|
||||
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
|
||||
}
|
||||
remoteName := remote.Config().Name
|
||||
|
||||
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var remoteRefName ReferenceName
|
||||
var localRefName ReferenceName
|
||||
for _, ref := range refs {
|
||||
if ref.Name().IsRemote() && ref.Hash().String() == sha {
|
||||
remoteRefName = ref.Name()
|
||||
}
|
||||
if ref.Name().IsBranch() && ref.Hash().String() == sha {
|
||||
localRefName = ref.Name()
|
||||
}
|
||||
}
|
||||
if remoteRefName == "" || localRefName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
|
||||
return b, b.Validate()
|
||||
}
|
||||
|
||||
// TeaFindBranchByName returns a branch that is at the the given local and
|
||||
// remote names and syncs to the given remote repo. This method is less precise
|
||||
// than TeaFindBranchBySha(), but may be desirable if local and remote branch
|
||||
// have diverged.
|
||||
func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *Branch, err error) {
|
||||
remote, err := r.GetRemote(repoURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remote == nil {
|
||||
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
|
||||
}
|
||||
remoteName := remote.Config().Name
|
||||
|
||||
refs, err := r.backend.ListReferences("refs/heads", "refs/remotes/"+remoteName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var remoteRefName ReferenceName
|
||||
var localRefName ReferenceName
|
||||
remoteSearchingName := fmt.Sprintf("%s/%s", remoteName, branchName)
|
||||
for _, ref := range refs {
|
||||
if ref.Name().IsRemote() && ref.Name().Short() == remoteSearchingName {
|
||||
remoteRefName = ref.Name()
|
||||
}
|
||||
if ref.Name().IsBranch() && ref.Name().Short() == branchName {
|
||||
localRefName = ref.Name()
|
||||
}
|
||||
}
|
||||
if remoteRefName == "" || localRefName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
b = &Branch{Remote: remoteName, Name: localRefName.Short(), Merge: localRefName}
|
||||
return b, b.Validate()
|
||||
}
|
||||
|
||||
// TeaFindBranchRemote gives the first remote that has a branch with the same name or sha,
|
||||
// depending on what is passed in.
|
||||
// This function is needed, as git does not always define branches in .git/config with remote entries.
|
||||
// Priority order is: first match of sha and branch -> first match of branch -> first match of sha.
|
||||
func (r TeaRepo) TeaFindBranchRemote(branchName, hash string) (*Remote, error) {
|
||||
remotes, err := r.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(remotes) == 0:
|
||||
return nil, nil
|
||||
case len(remotes) == 1:
|
||||
return remotes[0], nil
|
||||
}
|
||||
|
||||
refs, err := r.backend.ListReferences("refs/remotes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remoteByName := make(map[string]*Remote, len(remotes))
|
||||
for _, remote := range remotes {
|
||||
remoteByName[remote.Config().Name] = remote
|
||||
}
|
||||
|
||||
var shaMatch *Remote
|
||||
var branchMatch *Remote
|
||||
var fullMatch *Remote
|
||||
for _, ref := range refs {
|
||||
remoteName, remoteBranch, ok := splitRemoteRef(ref.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
remote := remoteByName[remoteName]
|
||||
if remote == nil {
|
||||
continue
|
||||
}
|
||||
if branchMatch == nil && branchName != "" && branchName == remoteBranch {
|
||||
branchMatch = remote
|
||||
}
|
||||
if shaMatch == nil && hash != "" && hash == ref.Hash().String() {
|
||||
shaMatch = remote
|
||||
}
|
||||
if fullMatch == nil && branchName != "" && branchName == remoteBranch && hash != "" && hash == ref.Hash().String() {
|
||||
fullMatch = remote
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case fullMatch != nil:
|
||||
return fullMatch, nil
|
||||
case branchMatch != nil:
|
||||
return branchMatch, nil
|
||||
case shaMatch != nil:
|
||||
return shaMatch, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TeaGetCurrentBranchNameAndSHA return the name and sha of the branch witch is currently active.
|
||||
func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) {
|
||||
localHead, err := r.Head()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !localHead.Name().IsBranch() {
|
||||
return "", "", fmt.Errorf("active ref is no branch")
|
||||
}
|
||||
|
||||
return localHead.Name().Short(), localHead.Hash().String(), nil
|
||||
}
|
||||
|
||||
// PushToCreatAgitFlowPR pushes the given head to the refs/for/<base>/<topic> ref on the remote to create an agit flow PR.
|
||||
func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, description string, auth *AuthMethod) error {
|
||||
if !strings.HasPrefix(head, "refs/") {
|
||||
head = "refs/heads/" + head
|
||||
}
|
||||
|
||||
pushOptions := make(map[string]string)
|
||||
if len(title) > 0 {
|
||||
pushOptions["title"] = b64Encode(title)
|
||||
}
|
||||
if len(description) > 0 {
|
||||
pushOptions["description"] = b64Encode(description)
|
||||
}
|
||||
return r.backend.PushToAgitFlowPR(remoteName, head, base, topic, pushOptions, auth)
|
||||
}
|
||||
|
||||
func splitRemoteRef(ref ReferenceName) (remoteName, branchName string, ok bool) {
|
||||
if !ref.IsRemote() {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(ref.Short(), "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// b64Encode implements base64 encode for string if necessary.
|
||||
func b64Encode(s string) string {
|
||||
if strings.Contains(s, "\n") || !isASCII(s) {
|
||||
return "{base64}" + base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// isASCII indicates string contains only ASCII.
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type cliBackend struct{}
|
||||
|
||||
type cliRepository struct {
|
||||
workTree string
|
||||
}
|
||||
|
||||
func (cliBackend) Name() string {
|
||||
return "cli"
|
||||
}
|
||||
|
||||
func (b cliBackend) Open(path string) (RepositoryBackend, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
out, err := runGitCommand(path, nil, nil, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return nil, classifyRepoError(err)
|
||||
}
|
||||
|
||||
return &cliRepository{workTree: strings.TrimSpace(out)}, nil
|
||||
}
|
||||
|
||||
func (b cliBackend) Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error) {
|
||||
extraConfigs := make([]string, 0, 1)
|
||||
if opts.Insecure {
|
||||
extraConfigs = append(extraConfigs, "http.sslVerify=false")
|
||||
}
|
||||
|
||||
args := []string{"clone"}
|
||||
if opts.Depth > 0 {
|
||||
args = append(args, "--depth", strconv.Itoa(opts.Depth))
|
||||
}
|
||||
args = append(args, remoteURL, path)
|
||||
|
||||
if _, err := runGitCommand("", auth, extraConfigs, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Open(path)
|
||||
}
|
||||
|
||||
func (r *cliRepository) WorkTree() string {
|
||||
return r.workTree
|
||||
}
|
||||
|
||||
func (r *cliRepository) Config() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Remotes: map[string]*RemoteConfig{},
|
||||
Branches: map[string]*Branch{},
|
||||
}
|
||||
|
||||
remoteOut, err := r.git(nil, nil, "remote")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, remoteName := range strings.Fields(remoteOut) {
|
||||
urlOut, err := r.git(nil, nil, "config", "--get-all", "remote."+remoteName+".url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Remotes[remoteName] = &RemoteConfig{Name: remoteName, URLs: splitNonEmptyLines(urlOut)}
|
||||
}
|
||||
|
||||
branchOut, err := r.git(nil, nil, "config", "--get-regexp", `^branch\..*\.(remote|merge)$`)
|
||||
if err != nil {
|
||||
var gitErr *gitCommandError
|
||||
if !errors.As(err, &gitErr) {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(gitErr.stderr) != "" && !strings.Contains(gitErr.stderr, "No such section or key") {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
for _, line := range splitNonEmptyLines(branchOut) {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
branchName, field, ok := parseBranchConfigKey(parts[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
branch := cfg.Branches[branchName]
|
||||
if branch == nil {
|
||||
branch = &Branch{Name: branchName}
|
||||
cfg.Branches[branchName] = branch
|
||||
}
|
||||
switch field {
|
||||
case "remote":
|
||||
branch.Remote = parts[1]
|
||||
case "merge":
|
||||
branch.Merge = ReferenceName(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (r *cliRepository) Head() (*Reference, error) {
|
||||
hashOut, err := r.git(nil, nil, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := Hash(strings.TrimSpace(hashOut))
|
||||
|
||||
if refOut, err := r.git(nil, nil, "symbolic-ref", "-q", "HEAD"); err == nil {
|
||||
return &Reference{name: ReferenceName(strings.TrimSpace(refOut)), hash: hash}, nil
|
||||
}
|
||||
if tagOut, err := r.git(nil, nil, "describe", "--exact-match", "--tags", "HEAD"); err == nil {
|
||||
return &Reference{name: ReferenceName("refs/tags/" + strings.TrimSpace(tagOut)), hash: hash}, nil
|
||||
}
|
||||
return &Reference{name: ReferenceName("HEAD"), hash: hash}, nil
|
||||
}
|
||||
|
||||
func (r *cliRepository) AddRemote(name, remoteURL string) error {
|
||||
_, err := r.git(nil, nil, "remote", "add", name, remoteURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
|
||||
mergeRef := NewBranchReferenceName(remoteBranch).String()
|
||||
if _, err := r.git(nil, nil, "config", "branch."+branchName+".remote", remoteName); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.git(nil, nil, "config", "branch."+branchName+".merge", mergeRef)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
|
||||
args := []string{"fetch", remoteName}
|
||||
args = append(args, refspecs...)
|
||||
_, err := r.git(auth, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error {
|
||||
_, err := r.git(nil, nil, "branch", "--track", localBranchName, fmt.Sprintf("%s/%s", remoteName, remoteBranchName))
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if gitErr, ok := err.(*gitCommandError); ok && strings.Contains(gitErr.stderr, "already exists") {
|
||||
return ErrBranchExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) Checkout(ref ReferenceName) error {
|
||||
args := []string{"checkout"}
|
||||
switch {
|
||||
case ref.IsBranch():
|
||||
// `git checkout refs/heads/<branch>` detaches HEAD, while the short branch
|
||||
// name switches to the local branch as intended.
|
||||
args = append(args, ref.Short())
|
||||
case ref.IsRemote():
|
||||
// Be explicit about detached HEAD when checking out a remote-tracking ref.
|
||||
args = append(args, "--detach", ref.String())
|
||||
default:
|
||||
args = append(args, ref.String())
|
||||
}
|
||||
|
||||
_, err := r.git(nil, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) DeleteLocalBranch(branchName string) error {
|
||||
_, err := r.git(nil, nil, "branch", "-D", branchName)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if gitErr, ok := err.(*gitCommandError); ok {
|
||||
stderr := strings.ToLower(gitErr.stderr)
|
||||
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "not exist") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
|
||||
_, err := r.git(auth, nil, "push", "--delete", remoteName, remoteBranch)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) ListReferences(prefixes ...string) ([]*Reference, error) {
|
||||
args := []string{"for-each-ref", "--format=%(objectname)%09%(refname)"}
|
||||
args = append(args, prefixes...)
|
||||
out, err := r.git(nil, nil, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]*Reference, 0)
|
||||
for _, line := range splitNonEmptyLines(out) {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, &Reference{name: ReferenceName(parts[1]), hash: Hash(parts[0])})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func (r *cliRepository) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
|
||||
ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic)
|
||||
args := []string{"push", remoteName, ref}
|
||||
if len(pushOptions) > 0 {
|
||||
keys := make([]string, 0, len(pushOptions))
|
||||
for key := range pushOptions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
args = append(args, "-o", key+"="+pushOptions[key])
|
||||
}
|
||||
}
|
||||
_, err := r.git(auth, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *cliRepository) git(auth *AuthMethod, extraConfigs []string, args ...string) (string, error) {
|
||||
return runGitCommand(r.workTree, auth, extraConfigs, args...)
|
||||
}
|
||||
|
||||
type gitCommandError struct {
|
||||
args []string
|
||||
stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *gitCommandError) Error() string {
|
||||
stderr := strings.TrimSpace(e.stderr)
|
||||
if stderr == "" {
|
||||
return fmt.Sprintf("git %s: %v", strings.Join(e.args, " "), e.err)
|
||||
}
|
||||
return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), stderr)
|
||||
}
|
||||
|
||||
func (e *gitCommandError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func gitCommandEnv(authEnv []string) []string {
|
||||
baseEnv := os.Environ()
|
||||
env := make([]string, 0, len(baseEnv)+len(authEnv)+3)
|
||||
for _, value := range baseEnv {
|
||||
if strings.HasPrefix(value, "LC_ALL=") ||
|
||||
strings.HasPrefix(value, "LANG=") ||
|
||||
strings.HasPrefix(value, "LANGUAGE=") {
|
||||
continue
|
||||
}
|
||||
env = append(env, value)
|
||||
}
|
||||
env = append(env, authEnv...)
|
||||
return append(env, "LC_ALL=C", "LANG=C", "LANGUAGE=C")
|
||||
}
|
||||
|
||||
func runGitCommand(dir string, auth *AuthMethod, extraConfigs []string, args ...string) (string, error) {
|
||||
authConfigs, authEnv, cleanup, err := prepareCLIAuth(auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
fullArgs := make([]string, 0, (len(extraConfigs)+len(authConfigs))*2+len(args))
|
||||
for _, cfg := range extraConfigs {
|
||||
fullArgs = append(fullArgs, "-c", cfg)
|
||||
}
|
||||
for _, cfg := range authConfigs {
|
||||
fullArgs = append(fullArgs, "-c", cfg)
|
||||
}
|
||||
fullArgs = append(fullArgs, args...)
|
||||
|
||||
cmd := exec.Command("git", fullArgs...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
cmd.Env = gitCommandEnv(authEnv)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", &gitCommandError{args: fullArgs, stderr: stderr.String(), err: err}
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func prepareCLIAuth(auth *AuthMethod) ([]string, []string, func(), error) {
|
||||
if auth == nil {
|
||||
return nil, nil, func() {}, nil
|
||||
}
|
||||
|
||||
configs := make([]string, 0, 1)
|
||||
env := make([]string, 0, 4)
|
||||
cleanup := func() {}
|
||||
|
||||
switch auth.Scheme {
|
||||
case "http", "https":
|
||||
if auth.Username != "" || auth.Password != "" {
|
||||
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(auth.Username+":"+auth.Password))
|
||||
configs = append(configs, "http.extraHeader="+header)
|
||||
}
|
||||
case "ssh":
|
||||
sshCommand := "ssh"
|
||||
if auth.KeyFile != "" {
|
||||
sshCommand += " -i " + shellQuote(auth.KeyFile) + " -o IdentitiesOnly=yes"
|
||||
}
|
||||
env = append(env, "GIT_SSH_COMMAND="+sshCommand)
|
||||
if auth.KeyPassphrase != "" {
|
||||
askPassPath, err := writeAskPassScript(auth.KeyPassphrase)
|
||||
if err != nil {
|
||||
return nil, nil, cleanup, err
|
||||
}
|
||||
cleanup = func() { _ = os.Remove(askPassPath) }
|
||||
env = append(env,
|
||||
"SSH_ASKPASS="+askPassPath,
|
||||
"SSH_ASKPASS_REQUIRE=force",
|
||||
"DISPLAY=tea",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return configs, env, cleanup, nil
|
||||
}
|
||||
|
||||
func writeAskPassScript(passphrase string) (string, error) {
|
||||
f, err := os.CreateTemp("", "tea-ssh-askpass-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content := "#!/bin/sh\nprintf '%s\\n' " + shellQuote(passphrase) + "\n"
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := f.Chmod(0o700); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
func classifyRepoError(err error) error {
|
||||
var gitErr *gitCommandError
|
||||
if errors.As(err, &gitErr) {
|
||||
msg := strings.ToLower(gitErr.stderr)
|
||||
if strings.Contains(msg, "not a git repository") || strings.Contains(msg, "cannot change to") {
|
||||
return ErrRepositoryNotExists
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func parseBranchConfigKey(key string) (branchName, field string, ok bool) {
|
||||
const prefix = "branch."
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
trimmed := strings.TrimPrefix(key, prefix)
|
||||
switch {
|
||||
case strings.HasSuffix(trimmed, ".remote"):
|
||||
return strings.TrimSuffix(trimmed, ".remote"), "remote", true
|
||||
case strings.HasSuffix(trimmed, ".merge"):
|
||||
return strings.TrimSuffix(trimmed, ".merge"), "merge", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func splitNonEmptyLines(s string) []string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenClassifiesLocalizedNotRepoError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test uses a POSIX shell script as fake git")
|
||||
}
|
||||
|
||||
fakeGitDir := t.TempDir()
|
||||
fakeGit := filepath.Join(fakeGitDir, "git")
|
||||
script := `#!/bin/sh
|
||||
locale="${LC_ALL:-${LANG:-C}}"
|
||||
if [ "$locale" = "C" ]; then
|
||||
echo "fatal: not a git repository (or any of the parent directories): .git" >&2
|
||||
else
|
||||
echo "fatal: Kein Git-Repository (oder irgendein Elternverzeichnis bis zum Einhangepunkt /)" >&2
|
||||
fi
|
||||
exit 128
|
||||
`
|
||||
require.NoError(t, os.WriteFile(fakeGit, []byte(script), 0o755))
|
||||
|
||||
t.Setenv("PATH", fakeGitDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
||||
t.Setenv("LANG", "de_DE.UTF-8")
|
||||
t.Setenv("LANGUAGE", "de_DE:de")
|
||||
|
||||
_, err := (cliBackend{}).Open(t.TempDir())
|
||||
require.ErrorIs(t, err, ErrRepositoryNotExists)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
// Clone clones a repository using the active backend.
|
||||
func Clone(path, remoteURL string, auth *AuthMethod, depth int, insecure bool) (*TeaRepo, error) {
|
||||
backend, err := currentBackend().Clone(path, remoteURL, auth, CloneOptions{Depth: depth, Insecure: insecure})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newTeaRepo(backend), nil
|
||||
}
|
||||
|
||||
// AddRemote adds a new remote to the repository.
|
||||
func (r TeaRepo) AddRemote(name, remoteURL string) error {
|
||||
return r.backend.AddRemote(name, remoteURL)
|
||||
}
|
||||
|
||||
// SetBranchUpstream configures the branch's upstream remote.
|
||||
func (r TeaRepo) SetBranchUpstream(branchName, remoteName, remoteBranch string) error {
|
||||
return r.backend.SetBranchUpstream(branchName, remoteName, remoteBranch)
|
||||
}
|
||||
|
||||
// Fetch fetches updates from the named remote.
|
||||
func (r TeaRepo) Fetch(remoteName string, refspecs []string, auth *AuthMethod) error {
|
||||
return r.backend.Fetch(remoteName, refspecs, auth)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// GetRemote tries to match a Remote of the repo via the given URL.
|
||||
// Matching is based on the normalized URL, accepting different protocols.
|
||||
func (r TeaRepo) GetRemote(remoteURL string) (*Remote, error) {
|
||||
repoURL, err := ParseURL(remoteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remotes, err := r.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, remote := range remotes {
|
||||
for _, u := range remote.Config().URLs {
|
||||
parsedRemoteURL, err := ParseURL(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedRemoteURL.Host == repoURL.Host && parsedRemoteURL.Path == repoURL.Path {
|
||||
return remote, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetOrCreateRemote tries to match a Remote of the repo via the given URL.
|
||||
// If no match is found, a new Remote with `newRemoteName` is created.
|
||||
// Matching is based on the normalized URL, accepting different protocols.
|
||||
func (r TeaRepo) GetOrCreateRemote(remoteURL, newRemoteName string) (*Remote, error) {
|
||||
localRemote, err := r.GetRemote(remoteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if localRemote == nil {
|
||||
if err := r.AddRemote(newRemoteName, remoteURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Remote(newRemoteName)
|
||||
}
|
||||
|
||||
return localRemote, nil
|
||||
}
|
||||
|
||||
// TeaRemoteURL returns the first url entry for the given remote name.
|
||||
func (r TeaRepo) TeaRemoteURL(name string) (auth *url.URL, err error) {
|
||||
remote, err := r.Remote(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return nil, fmt.Errorf("remote %s has no URL configured", name)
|
||||
}
|
||||
return ParseURL(urls[0])
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// TeaRepo wraps a local git repository behind a swappable backend.
|
||||
type TeaRepo struct {
|
||||
backend RepositoryBackend
|
||||
}
|
||||
|
||||
func newTeaRepo(backend RepositoryBackend) *TeaRepo {
|
||||
return &TeaRepo{backend: backend}
|
||||
}
|
||||
|
||||
// RepoForWorkdir tries to open the git repository in the local directory
|
||||
// for reading or modification.
|
||||
func RepoForWorkdir() (*TeaRepo, error) {
|
||||
return RepoFromPath("")
|
||||
}
|
||||
|
||||
// RepoFromPath tries to open the git repository by path.
|
||||
func RepoFromPath(path string) (*TeaRepo, error) {
|
||||
backend, err := currentBackend().Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newTeaRepo(backend), nil
|
||||
}
|
||||
|
||||
// WorkTree returns the repository work tree path.
|
||||
func (r TeaRepo) WorkTree() string {
|
||||
return r.backend.WorkTree()
|
||||
}
|
||||
|
||||
// Config returns the repository config values tea needs.
|
||||
func (r TeaRepo) Config() (*Config, error) {
|
||||
return r.backend.Config()
|
||||
}
|
||||
|
||||
// Remote returns the configured remote by name.
|
||||
func (r TeaRepo) Remote(remoteName string) (*Remote, error) {
|
||||
cfg, err := r.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteCfg, ok := cfg.Remotes[remoteName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("remote %s not found", remoteName)
|
||||
}
|
||||
return &Remote{repo: &r, config: remoteCfg}, nil
|
||||
}
|
||||
|
||||
// Remotes returns all configured remotes sorted by name.
|
||||
func (r TeaRepo) Remotes() ([]*Remote, error) {
|
||||
cfg, err := r.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remoteNames := make([]string, 0, len(cfg.Remotes))
|
||||
for name := range cfg.Remotes {
|
||||
remoteNames = append(remoteNames, name)
|
||||
}
|
||||
sort.Strings(remoteNames)
|
||||
|
||||
remotes := make([]*Remote, 0, len(remoteNames))
|
||||
for _, name := range remoteNames {
|
||||
remotes = append(remotes, &Remote{repo: &r, config: cfg.Remotes[name]})
|
||||
}
|
||||
return remotes, nil
|
||||
}
|
||||
|
||||
// Head returns the currently checked out ref.
|
||||
func (r TeaRepo) Head() (*Reference, error) {
|
||||
return r.backend.Head()
|
||||
}
|
||||
|
||||
// RemoteURL returns the URL of the given remote.
|
||||
func (r TeaRepo) RemoteURL(remoteName string) (*url.URL, error) {
|
||||
remote, err := r.Remote(remoteName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(remote.Config().URLs) == 0 {
|
||||
return nil, fmt.Errorf("remote %s has no URL configured", remoteName)
|
||||
}
|
||||
return ParseURL(remote.Config().URLs[0])
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoErrorf(t, err, "git %v failed: %s", args, out)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func tryGit(dir string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
_, err := cmd.CombinedOutput()
|
||||
return err
|
||||
}
|
||||
|
||||
func TestRepoFromPathSupportsWorktrees(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
mainRepoPath := filepath.Join(tmpDir, "main-repo")
|
||||
worktreePath := filepath.Join(tmpDir, "worktree")
|
||||
|
||||
runGit(t, "", "init", mainRepoPath)
|
||||
runGit(t, mainRepoPath, "config", "user.email", "test@example.com")
|
||||
runGit(t, mainRepoPath, "config", "user.name", "Test User")
|
||||
runGit(t, mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
||||
|
||||
readmePath := filepath.Join(mainRepoPath, "README.md")
|
||||
require.NoError(t, os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644))
|
||||
runGit(t, mainRepoPath, "add", "README.md")
|
||||
runGit(t, mainRepoPath, "commit", "-m", "Initial commit")
|
||||
runGit(t, mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
|
||||
|
||||
repo, err := RepoFromPath(worktreePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
config, err := repo.Config()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, config.Remotes, "origin")
|
||||
require.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0])
|
||||
|
||||
head, err := repo.Head()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-branch", head.Name().Short())
|
||||
}
|
||||
|
||||
func TestRepoFromPathSupportsSHA256Repos(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
repoPath := filepath.Join(tmpDir, "sha256-repo")
|
||||
|
||||
if err := tryGit("", "init", "--object-format=sha256", repoPath); err != nil {
|
||||
t.Skip("git does not support sha256 object format in this environment")
|
||||
}
|
||||
|
||||
runGit(t, repoPath, "config", "user.email", "test@example.com")
|
||||
runGit(t, repoPath, "config", "user.name", "Test User")
|
||||
runGit(t, repoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
||||
|
||||
readmePath := filepath.Join(repoPath, "README.md")
|
||||
require.NoError(t, os.WriteFile(readmePath, []byte("sha256\n"), 0o644))
|
||||
runGit(t, repoPath, "add", "README.md")
|
||||
runGit(t, repoPath, "commit", "-m", "Initial commit")
|
||||
|
||||
repo, err := RepoFromPath(repoPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
branch, sha, err := repo.TeaGetCurrentBranchNameAndSHA()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, branch)
|
||||
require.Len(t, sha, 64)
|
||||
|
||||
config, err := repo.Config()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, config.Remotes, "origin")
|
||||
}
|
||||
|
||||
func TestTeaFindBranchByShaAndName(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
remotePath := filepath.Join(tmpDir, "remote.git")
|
||||
localPath := filepath.Join(tmpDir, "local")
|
||||
|
||||
runGit(t, "", "init", "--bare", remotePath)
|
||||
runGit(t, "", "clone", remotePath, localPath)
|
||||
runGit(t, localPath, "config", "user.email", "test@example.com")
|
||||
runGit(t, localPath, "config", "user.name", "Test User")
|
||||
|
||||
filePath := filepath.Join(localPath, "README.md")
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("main\n"), 0o644))
|
||||
runGit(t, localPath, "add", "README.md")
|
||||
runGit(t, localPath, "commit", "-m", "Initial commit")
|
||||
runGit(t, localPath, "branch", "-M", "main")
|
||||
runGit(t, localPath, "push", "-u", "origin", "main")
|
||||
runGit(t, localPath, "checkout", "-b", "feature/demo")
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("feature\n"), 0o644))
|
||||
runGit(t, localPath, "commit", "-am", "Feature commit")
|
||||
runGit(t, localPath, "push", "-u", "origin", "feature/demo")
|
||||
|
||||
repo, err := RepoFromPath(localPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
sha := strings.TrimSpace(runGit(t, localPath, "rev-parse", "HEAD"))
|
||||
branchBySha, err := repo.TeaFindBranchBySha(sha, remotePath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, branchBySha)
|
||||
require.Equal(t, "feature/demo", branchBySha.Name)
|
||||
require.Equal(t, "origin", branchBySha.Remote)
|
||||
|
||||
branchByName, err := repo.TeaFindBranchByName("feature/demo", remotePath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, branchByName)
|
||||
require.Equal(t, "feature/demo", branchByName.Name)
|
||||
|
||||
remote, err := repo.TeaFindBranchRemote("feature/demo", sha)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, remote)
|
||||
require.Equal(t, "origin", remote.Config().Name)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRepositoryNotExists indicates the requested path is not inside a git repository.
|
||||
ErrRepositoryNotExists = errors.New("repository does not exist")
|
||||
// ErrBranchExists indicates the requested branch already exists locally.
|
||||
ErrBranchExists = errors.New("branch already exists")
|
||||
)
|
||||
|
||||
// AuthMethod carries backend-agnostic authentication information for git operations.
|
||||
type AuthMethod struct {
|
||||
Scheme string
|
||||
Username string
|
||||
Password string
|
||||
KeyFile string
|
||||
KeyPassphrase string
|
||||
}
|
||||
|
||||
// CloneOptions describes repository clone behavior.
|
||||
type CloneOptions struct {
|
||||
Depth int
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// RepositoryBackend is the backend abstraction used by TeaRepo.
|
||||
type RepositoryBackend interface {
|
||||
WorkTree() string
|
||||
Config() (*Config, error)
|
||||
Head() (*Reference, error)
|
||||
AddRemote(name, remoteURL string) error
|
||||
SetBranchUpstream(branchName, remoteName, remoteBranch string) error
|
||||
Fetch(remoteName string, refspecs []string, auth *AuthMethod) error
|
||||
CreateTrackingBranch(localBranchName, remoteBranchName, remoteName string) error
|
||||
Checkout(ref ReferenceName) error
|
||||
DeleteLocalBranch(branchName string) error
|
||||
DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error
|
||||
ListReferences(prefixes ...string) ([]*Reference, error)
|
||||
PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error
|
||||
}
|
||||
|
||||
// Backend opens and clones repositories using a concrete git implementation.
|
||||
type Backend interface {
|
||||
Name() string
|
||||
Open(path string) (RepositoryBackend, error)
|
||||
Clone(path, remoteURL string, auth *AuthMethod, opts CloneOptions) (RepositoryBackend, error)
|
||||
}
|
||||
|
||||
// Config mirrors the repository config fields tea needs.
|
||||
type Config struct {
|
||||
Remotes map[string]*RemoteConfig
|
||||
Branches map[string]*Branch
|
||||
}
|
||||
|
||||
// RemoteConfig stores remote configuration.
|
||||
type RemoteConfig struct {
|
||||
Name string
|
||||
URLs []string
|
||||
}
|
||||
|
||||
// Branch stores branch configuration.
|
||||
type Branch struct {
|
||||
Name string
|
||||
Remote string
|
||||
Merge ReferenceName
|
||||
}
|
||||
|
||||
// Validate checks whether the branch contains the fields tea needs.
|
||||
func (b *Branch) Validate() error {
|
||||
if b == nil || b.Name == "" {
|
||||
return errors.New("branch name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remote wraps a configured git remote.
|
||||
type Remote struct {
|
||||
repo *TeaRepo
|
||||
config *RemoteConfig
|
||||
}
|
||||
|
||||
// Config returns the remote configuration.
|
||||
func (r *Remote) Config() *RemoteConfig {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.config
|
||||
}
|
||||
|
||||
// ReferenceName identifies a git reference.
|
||||
type ReferenceName string
|
||||
|
||||
func (r ReferenceName) String() string { return string(r) }
|
||||
|
||||
// Short returns the short display name for the reference.
|
||||
func (r ReferenceName) Short() string {
|
||||
s := string(r)
|
||||
switch {
|
||||
case strings.HasPrefix(s, "refs/heads/"):
|
||||
return strings.TrimPrefix(s, "refs/heads/")
|
||||
case strings.HasPrefix(s, "refs/remotes/"):
|
||||
return strings.TrimPrefix(s, "refs/remotes/")
|
||||
case strings.HasPrefix(s, "refs/tags/"):
|
||||
return strings.TrimPrefix(s, "refs/tags/")
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// IsBranch reports whether the reference points to a local branch.
|
||||
func (r ReferenceName) IsBranch() bool {
|
||||
return strings.HasPrefix(string(r), "refs/heads/")
|
||||
}
|
||||
|
||||
// IsRemote reports whether the reference points to a remote-tracking branch.
|
||||
func (r ReferenceName) IsRemote() bool {
|
||||
return strings.HasPrefix(string(r), "refs/remotes/")
|
||||
}
|
||||
|
||||
// IsTag reports whether the reference points to a tag.
|
||||
func (r ReferenceName) IsTag() bool {
|
||||
return strings.HasPrefix(string(r), "refs/tags/")
|
||||
}
|
||||
|
||||
// Hash wraps a git object id.
|
||||
type Hash string
|
||||
|
||||
func (h Hash) String() string { return string(h) }
|
||||
|
||||
// Reference stores a resolved git ref and its hash.
|
||||
type Reference struct {
|
||||
name ReferenceName
|
||||
hash Hash
|
||||
}
|
||||
|
||||
// Name returns the reference name.
|
||||
func (r *Reference) Name() ReferenceName { return r.name }
|
||||
|
||||
// Hash returns the reference hash.
|
||||
func (r *Reference) Hash() Hash { return r.hash }
|
||||
|
||||
// NewBranchReferenceName constructs a local branch ref name.
|
||||
func NewBranchReferenceName(name string) ReferenceName {
|
||||
if strings.HasPrefix(name, "refs/") {
|
||||
return ReferenceName(name)
|
||||
}
|
||||
return ReferenceName("refs/heads/" + name)
|
||||
}
|
||||
|
||||
// NewRemoteReferenceName constructs a remote-tracking ref name.
|
||||
func NewRemoteReferenceName(remote, name string) ReferenceName {
|
||||
if strings.HasPrefix(name, "refs/") {
|
||||
return ReferenceName(name)
|
||||
}
|
||||
return ReferenceName("refs/remotes/" + remote + "/" + name)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
|
||||
|
||||
// URLParser represents a git URL parser
|
||||
type URLParser struct{}
|
||||
|
||||
// Parse parses the git URL
|
||||
func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
|
||||
if !protocolRe.MatchString(rawURL) {
|
||||
// convert the weird git ssh url format to a canonical url:
|
||||
// git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea
|
||||
if strings.Contains(rawURL, ":") &&
|
||||
// not a Windows path
|
||||
!strings.Contains(rawURL, "\\") {
|
||||
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
|
||||
} else if !strings.Contains(rawURL, "@") &&
|
||||
strings.Count(rawURL, "/") == 2 {
|
||||
// match cases like gitea.com/gitea/tea
|
||||
rawURL = "https://" + rawURL
|
||||
}
|
||||
}
|
||||
|
||||
u, err = url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if u.Scheme == "git+ssh" {
|
||||
u.Scheme = "ssh"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(u.Path, "//") {
|
||||
u.Path = strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
|
||||
// .git suffix is optional and breaks normalization
|
||||
u.Path = strings.TrimSuffix(u.Path, ".git")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ParseURL parses URL string and return URL struct
|
||||
func ParseURL(rawURL string) (u *url.URL, err error) {
|
||||
p := &URLParser{}
|
||||
return p.Parse(rawURL)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseUrl(t *testing.T) {
|
||||
u, err := ParseURL("ssh://git@gitea.com:3000/gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com:3000", u.Host)
|
||||
assert.Equal(t, "ssh", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("https://gitea.com/gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com", u.Host)
|
||||
assert.Equal(t, "https", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("git@gitea.com:gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com", u.Host)
|
||||
assert.Equal(t, "ssh", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("gitea.com/gitea/tea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "gitea.com", u.Host)
|
||||
assert.Equal(t, "https", u.Scheme)
|
||||
assert.Equal(t, "/gitea/tea", u.Path)
|
||||
|
||||
u, err = ParseURL("foo/bar")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", u.Host)
|
||||
assert.Equal(t, "", u.Scheme)
|
||||
assert.Equal(t, "foo/bar", u.Path)
|
||||
|
||||
u, err = ParseURL("/foo/bar")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", u.Host)
|
||||
assert.Equal(t, "https", u.Scheme)
|
||||
assert.Equal(t, "/foo/bar", u.Path)
|
||||
|
||||
// this case is unintuitive, but to ambiguous to be handled differently
|
||||
u, err = ParseURL("gitea.com")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", u.Host)
|
||||
assert.Equal(t, "", u.Scheme)
|
||||
assert.Equal(t, "gitea.com", u.Path)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"gitea.dev/tea/modules/version"
|
||||
)
|
||||
|
||||
// UserAgent returns the standard User-Agent string for tea.
|
||||
func UserAgent() string {
|
||||
ua := fmt.Sprintf("tea/%s (%s/%s)", version.Version, runtime.GOOS, runtime.GOARCH)
|
||||
if version.SDK != "" {
|
||||
ua += fmt.Sprintf(" go-sdk/%s", version.SDK)
|
||||
}
|
||||
return ua
|
||||
}
|
||||
|
||||
// WrapTransport returns tea's standard HTTP transport: an *http.Transport
|
||||
// preset with tea's connection / response-header timeouts (see timeoutTransport)
|
||||
// and decorated to add the User-Agent header on every request. The supplied
|
||||
// tlsConfig is attached as-is (nil is fine); callers use it for insecure /
|
||||
// skip-verify logins. This is the single entry point for building a tea HTTP
|
||||
// client transport, so the timeouts can't be accidentally omitted.
|
||||
func WrapTransport(tlsConfig *tls.Config) http.RoundTripper {
|
||||
return &userAgentTransport{base: timeoutTransport(tlsConfig)}
|
||||
}
|
||||
|
||||
type userAgentTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Set the UA at the transport so every client built from WrapTransport
|
||||
// identifies itself, including the non-SDK clients (oauth2 flow, token
|
||||
// refresh) that never pass through the SDK's own SetUserAgent. For SDK
|
||||
// clients this overlaps gitea.SetUserAgent; both use httputil.UserAgent(),
|
||||
// so the duplicate Header.Set is a no-op.
|
||||
req.Header.Set("User-Agent", UserAgent())
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timeout values applied to every Gitea API request. These are deliberately
|
||||
// connection-establishment and time-to-first-response-byte timeouts, NOT an
|
||||
// overall request deadline: a large release-attachment upload can legitimately
|
||||
// run for minutes, and as long as bytes keep flowing none of these fire. They
|
||||
// only trip when a server accepts the connection but never (or far too slowly)
|
||||
// starts responding — the "hangs forever" case from a stalled or unresponsive
|
||||
// server (issue #1018).
|
||||
const (
|
||||
// DialTimeout bounds establishing the TCP connection.
|
||||
DialTimeout = 10 * time.Second
|
||||
// TLSHandshakeTimeout bounds completing the TLS handshake.
|
||||
TLSHandshakeTimeout = 10 * time.Second
|
||||
// ResponseHeaderTimeout bounds the wait, after the request is written, for
|
||||
// the server to begin sending response headers. This is the only timeout
|
||||
// that protects against a server which accepts the connection but then goes
|
||||
// silent — the originally reported #1018 symptom; DialTimeout/
|
||||
// TLSHandshakeTimeout do not, because the connection already succeeded.
|
||||
//
|
||||
// The value must clear Gitea's legitimate synchronous pre-response work.
|
||||
// Profiling a self-hosted Gitea 1.24.6 (on hardware slower than gitea.com)
|
||||
// showed creating a pull request that triggers conflict detection across
|
||||
// ~1500 changed files takes ~10s before the first byte (3 runs: 10.06 /
|
||||
// 10.08 / 10.24s); clean-diff PR creation was ~1s and large attachment
|
||||
// uploads ~9ms. 120s is ~12x that measured worst case, leaving generous
|
||||
// headroom for larger repos and busier servers while still failing in two
|
||||
// minutes instead of hanging forever.
|
||||
ResponseHeaderTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// timeoutTransport returns an *http.Transport configured with tea's standard
|
||||
// timeouts. The supplied tlsConfig is attached as-is (callers use it for
|
||||
// insecure / skip-verify logins). It is a clone of http.DefaultTransport so
|
||||
// connection pooling, proxy support and HTTP/2 keep working. Callers obtain it
|
||||
// through WrapTransport, which also adds the User-Agent header.
|
||||
func timeoutTransport(tlsConfig *tls.Config) *http.Transport {
|
||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.DialContext = (&net.Dialer{
|
||||
Timeout: DialTimeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext
|
||||
t.TLSHandshakeTimeout = TLSHandshakeTimeout
|
||||
t.ResponseHeaderTimeout = ResponseHeaderTimeout
|
||||
t.TLSClientConfig = tlsConfig
|
||||
return t
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWrapTransportTimeouts verifies the transport returned by WrapTransport
|
||||
// carries tea's standard timeout values, so a stalled server can't make tea
|
||||
// hang forever (issue #1018).
|
||||
func TestWrapTransportTimeouts(t *testing.T) {
|
||||
rt := WrapTransport(nil)
|
||||
uat, ok := rt.(*userAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("WrapTransport returned %T, want *userAgentTransport", rt)
|
||||
}
|
||||
tr, ok := uat.base.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("underlying base is %T, want *http.Transport", uat.base)
|
||||
}
|
||||
if tr.TLSHandshakeTimeout != TLSHandshakeTimeout {
|
||||
t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, TLSHandshakeTimeout)
|
||||
}
|
||||
if tr.ResponseHeaderTimeout != ResponseHeaderTimeout {
|
||||
t.Errorf("ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, ResponseHeaderTimeout)
|
||||
}
|
||||
if tr.DialContext == nil {
|
||||
t.Error("DialContext is nil, want a dialer with DialTimeout")
|
||||
}
|
||||
}
|
||||
|
||||
// newStallListener returns a listener that accepts connections, reads the
|
||||
// request, then goes silent without ever sending response headers — the
|
||||
// "server accepts the connection but never responds" case ResponseHeaderTimeout
|
||||
// guards against. The returned closer stops the listener.
|
||||
func newStallListener(t *testing.T) (addr string, closer func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = c.Read(buf) // drain the request, then never respond
|
||||
<-done // hold the connection open until the test ends
|
||||
c.Close()
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
close(done)
|
||||
ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseHeaderTimeoutFires proves a request to a server that accepts the
|
||||
// connection and request but never sends response headers aborts via
|
||||
// ResponseHeaderTimeout rather than hanging. It builds the transport the same
|
||||
// way WrapTransport does, with a short ResponseHeaderTimeout so the test is fast.
|
||||
func TestResponseHeaderTimeoutFires(t *testing.T) {
|
||||
addr, closer := newStallListener(t)
|
||||
defer closer()
|
||||
|
||||
tr := timeoutTransport(nil)
|
||||
tr.ResponseHeaderTimeout = 2 * time.Second
|
||||
client := &http.Client{Transport: &userAgentTransport{base: tr}}
|
||||
|
||||
start := time.Now()
|
||||
_, err := client.Get("http://" + addr + "/")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout error from stalled server, got nil")
|
||||
}
|
||||
if elapsed > 10*time.Second {
|
||||
t.Errorf("request took %v; ResponseHeaderTimeout did not fire", elapsed)
|
||||
}
|
||||
t.Logf("request failed as expected after %v: %v", elapsed, err)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/cmd/flags"
|
||||
"gitea.dev/tea/modules/context"
|
||||
"gitea.dev/tea/modules/print"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ShowCommentsMaybeInteractive fetches & prints comments, depending on the --comments flag.
|
||||
// If that flag is unset, and output is not piped, prompts the user first.
|
||||
func ShowCommentsMaybeInteractive(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, totalComments int) error {
|
||||
if ctx.Bool("comments") {
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
|
||||
c := ctx.Login.Client()
|
||||
comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
print.Comments(comments)
|
||||
} else if print.IsInteractive() && !ctx.IsSet("comments") {
|
||||
// if we're interactive, but --comments hasn't been explicitly set to false
|
||||
if err := ShowCommentsPaginated(requestCtx, ctx, idx, totalComments); err != nil {
|
||||
fmt.Printf("error while loading comments: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowCommentsPaginated prompts if issue/pr comments should be shown and continues to do so.
|
||||
func ShowCommentsPaginated(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, totalComments int) error {
|
||||
c := ctx.Login.Client()
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
|
||||
prompt := "show comments?"
|
||||
commentsLoaded := 0
|
||||
|
||||
// paginated fetch
|
||||
// NOTE: as of gitea 1.13, pagination is not provided by this endpoint, but handles
|
||||
// this function gracefully anyways.
|
||||
for {
|
||||
loadComments := true
|
||||
if err := huh.NewConfirm().
|
||||
Title(prompt).
|
||||
Value(&loadComments).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
} else if !loadComments {
|
||||
break
|
||||
} else {
|
||||
if comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, opts); err != nil {
|
||||
return err
|
||||
} else if len(comments) != 0 {
|
||||
print.Comments(comments)
|
||||
commentsLoaded += len(comments)
|
||||
}
|
||||
if commentsLoaded >= totalComments {
|
||||
break
|
||||
}
|
||||
opts.ListOptions.Page++
|
||||
prompt = "load more?"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsStdinPiped checks if stdin is piped
|
||||
func IsStdinPiped() bool {
|
||||
return !term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/print"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// IsQuitting checks if the user has aborted the interactive prompt
|
||||
func IsQuitting(err error) bool {
|
||||
return err == huh.ErrUserAborted
|
||||
}
|
||||
|
||||
// CreateIssue interactively creates an issue
|
||||
func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) error {
|
||||
owner, repo, err := promptRepoSlug(owner, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Target repo:", owner+"/"+repo)
|
||||
|
||||
var opts gitea.CreateIssueOption
|
||||
if err := promptIssueProperties(ctx, login, owner, repo, &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issue, err := task.CreateIssue(ctx, login, owner, repo, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.IssueDetails(issue, nil)
|
||||
|
||||
fmt.Println(issue.HTMLURL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error {
|
||||
var milestoneName string
|
||||
var err error
|
||||
|
||||
selectableChan := make(chan issueSelectables, 1)
|
||||
go fetchIssueSelectables(ctx, login, owner, repo, selectableChan)
|
||||
|
||||
// title
|
||||
if err := huh.NewInput().
|
||||
Title("Issue title:").
|
||||
Value(&o.Title).
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Issue title:", o.Title)
|
||||
|
||||
// description
|
||||
if err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Issue description(markdown):").
|
||||
ExternalEditor(config.GetPreferences().Editor).
|
||||
EditorExtension("md").
|
||||
Value(&o.Body),
|
||||
),
|
||||
).WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Issue description(markdown):", o.Body)
|
||||
|
||||
// wait until selectables are fetched
|
||||
selectables := <-selectableChan
|
||||
if selectables.Err != nil {
|
||||
return selectables.Err
|
||||
}
|
||||
|
||||
// skip remaining props if we don't have permission to set them
|
||||
if !selectables.Repo.Permissions.Push {
|
||||
return nil
|
||||
}
|
||||
|
||||
// assignees
|
||||
if o.Assignees, err = promptMultiSelect("Assignees:", selectables.Assignees, "[other]"); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Assignees:", strings.Join(o.Assignees, "\n"))
|
||||
|
||||
// milestone
|
||||
if len(selectables.MilestoneList) != 0 {
|
||||
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Milestone = selectables.MilestoneMap[milestoneName]
|
||||
printTitleAndContent("Milestone:", milestoneName)
|
||||
}
|
||||
|
||||
// labels
|
||||
if len(selectables.LabelList) != 0 {
|
||||
options := make([]huh.Option[int64], 0, len(selectables.LabelList))
|
||||
labelsMap := make(map[int64]string, len(selectables.LabelList))
|
||||
for _, l := range selectables.LabelList {
|
||||
options = append(options, huh.Option[int64]{Key: l, Value: selectables.LabelMap[l]})
|
||||
labelsMap[selectables.LabelMap[l]] = l
|
||||
}
|
||||
if err := huh.NewMultiSelect[int64]().
|
||||
Title("Labels:").
|
||||
Options(options...).
|
||||
Value(&o.Labels).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
var labels []string
|
||||
for _, labelID := range o.Labels {
|
||||
labels = append(labels, labelsMap[labelID])
|
||||
}
|
||||
printTitleAndContent("Labels:", strings.Join(labels, "\n"))
|
||||
}
|
||||
|
||||
// deadline
|
||||
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
|
||||
return err
|
||||
}
|
||||
deadlineStr := "No due date"
|
||||
if o.Deadline != nil && !o.Deadline.IsZero() {
|
||||
deadlineStr = o.Deadline.Format("2006-01-02")
|
||||
}
|
||||
printTitleAndContent("Due date:", deadlineStr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type issueSelectables struct {
|
||||
Repo *gitea.Repository
|
||||
Assignees []string
|
||||
MilestoneList []string
|
||||
MilestoneMap map[string]int64
|
||||
LabelList []string
|
||||
LabelMap map[string]int64
|
||||
Err error
|
||||
}
|
||||
|
||||
func fetchIssueSelectables(ctx context.Context, login *config.Login, owner, repo string, done chan issueSelectables) {
|
||||
// TODO PERF make these calls concurrent
|
||||
r := issueSelectables{}
|
||||
c := login.Client()
|
||||
|
||||
r.Repo, _, r.Err = c.Repositories.GetRepo(ctx, owner, repo)
|
||||
if r.Err != nil {
|
||||
done <- r
|
||||
return
|
||||
}
|
||||
// we can set the following properties only if we have write access to the repo
|
||||
// so we fastpath this if not.
|
||||
if !r.Repo.Permissions.Push {
|
||||
done <- r
|
||||
return
|
||||
}
|
||||
|
||||
assignees, _, err := c.Repositories.GetAssignees(ctx, owner, repo)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
done <- r
|
||||
return
|
||||
}
|
||||
r.Assignees = make([]string, len(assignees))
|
||||
for i, u := range assignees {
|
||||
r.Assignees[i] = u.UserName
|
||||
}
|
||||
|
||||
milestones, _, err := c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{})
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
done <- r
|
||||
return
|
||||
}
|
||||
r.MilestoneMap = make(map[string]int64)
|
||||
r.MilestoneList = make([]string, len(milestones))
|
||||
for i, m := range milestones {
|
||||
r.MilestoneMap[m.Title] = m.ID
|
||||
r.MilestoneList[i] = m.Title
|
||||
}
|
||||
|
||||
r.LabelMap = make(map[string]int64)
|
||||
r.LabelList = make([]string, 0)
|
||||
for page := 1; ; {
|
||||
labels, resp, err := c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
done <- r
|
||||
return
|
||||
}
|
||||
for _, l := range labels {
|
||||
r.LabelMap[l.Name] = l.ID
|
||||
r.LabelList = append(r.LabelList, l.Name)
|
||||
}
|
||||
if resp == nil || resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
done <- r
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/context"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// EditIssue interactively edits an issue
|
||||
func EditIssue(requestCtx stdctx.Context, ctx context.TeaContext, index int64) (*task.EditIssueOption, error) {
|
||||
opts := task.EditIssueOption{}
|
||||
var err error
|
||||
|
||||
ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo)
|
||||
if err != nil {
|
||||
return &opts, err
|
||||
}
|
||||
printTitleAndContent("Target repo:", ctx.Owner+"/"+ctx.Repo)
|
||||
|
||||
c := ctx.Login.Client()
|
||||
i, _, err := c.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, index)
|
||||
if err != nil {
|
||||
return &opts, err
|
||||
}
|
||||
|
||||
opts = task.EditIssueOption{
|
||||
Index: index,
|
||||
Title: &i.Title,
|
||||
Body: &i.Body,
|
||||
Deadline: i.Deadline,
|
||||
}
|
||||
|
||||
i.Assignees = cleanAssignees(i.Assignees)
|
||||
if len(i.Assignees) != 0 {
|
||||
for _, a := range i.Assignees {
|
||||
opts.SetAssignees = append(opts.SetAssignees, a.UserName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(i.Labels) != 0 {
|
||||
for _, l := range i.Labels {
|
||||
opts.AddLabels = append(opts.AddLabels, l.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if i.Milestone != nil {
|
||||
opts.Milestone = &i.Milestone.Title
|
||||
}
|
||||
|
||||
if err := promptIssueEditProperties(requestCtx, &ctx, &opts); err != nil {
|
||||
return &opts, err
|
||||
}
|
||||
|
||||
return &opts, err
|
||||
}
|
||||
|
||||
func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContext, o *task.EditIssueOption) error {
|
||||
var milestoneName string
|
||||
var labelsSelected []string
|
||||
var err error
|
||||
|
||||
selectableChan := make(chan issueSelectables, 1)
|
||||
go fetchIssueSelectables(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, selectableChan)
|
||||
|
||||
// title
|
||||
if err := huh.NewInput().
|
||||
Title("Issue title:").
|
||||
Value(o.Title).
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Issue title:", *o.Title)
|
||||
|
||||
// description
|
||||
if err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Issue description(markdown):").
|
||||
ExternalEditor(config.GetPreferences().Editor).
|
||||
EditorExtension("md").
|
||||
Value(o.Body),
|
||||
),
|
||||
).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Issue description(markdown):", *o.Body)
|
||||
|
||||
// wait until selectables are fetched
|
||||
selectables := <-selectableChan
|
||||
if selectables.Err != nil {
|
||||
return selectables.Err
|
||||
}
|
||||
|
||||
// skip remaining props if we don't have permission to set them
|
||||
if !selectables.Repo.Permissions.Push {
|
||||
return nil
|
||||
}
|
||||
|
||||
currAssignees := o.SetAssignees
|
||||
newAssignees := selectables.Assignees
|
||||
|
||||
for _, c := range currAssignees {
|
||||
if i := slices.Index(newAssignees, c); i != -1 {
|
||||
newAssignees = slices.Delete(newAssignees, i, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// assignees
|
||||
if currAssignees, err = promptMultiSelectWithPreselect("Set Assignees:", currAssignees, newAssignees, "[other]"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(currAssignees) == 0 && len(o.SetAssignees) > 0 {
|
||||
o.RemoveAssignees = o.SetAssignees
|
||||
}
|
||||
o.SetAssignees = currAssignees
|
||||
printTitleAndContent("Assignees:", strings.Join(o.SetAssignees, "\n"))
|
||||
|
||||
// milestone
|
||||
if len(selectables.MilestoneList) != 0 {
|
||||
var defaultMS string
|
||||
if o.Milestone != nil {
|
||||
defaultMS = *o.Milestone
|
||||
}
|
||||
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", defaultMS); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Milestone = &milestoneName
|
||||
printTitleAndContent("Milestone:", milestoneName)
|
||||
}
|
||||
|
||||
// labels
|
||||
if len(selectables.LabelList) != 0 {
|
||||
copy(labelsSelected, o.AddLabels)
|
||||
if err := huh.NewMultiSelect[string]().
|
||||
Title("Labels:").
|
||||
Options(huh.NewOptions(selectables.LabelList...)...).
|
||||
Value(&labelsSelected).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Labels:", strings.Join(labelsSelected, "\n"))
|
||||
|
||||
// removed labels
|
||||
for _, l := range o.AddLabels {
|
||||
if !slices.Contains(labelsSelected, l) {
|
||||
o.RemoveLabels = append(o.RemoveLabels, l)
|
||||
}
|
||||
}
|
||||
// added labels
|
||||
o.AddLabels = make([]string, len(labelsSelected))
|
||||
for i, l := range labelsSelected {
|
||||
o.AddLabels[i] = l
|
||||
}
|
||||
}
|
||||
|
||||
// deadline
|
||||
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
|
||||
return err
|
||||
}
|
||||
deadlineStr := "No due date"
|
||||
if o.Deadline != nil && !o.Deadline.IsZero() {
|
||||
deadlineStr = o.Deadline.Format("2006-01-02")
|
||||
}
|
||||
printTitleAndContent("Due date:", deadlineStr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanAssignees(list []*gitea.User) []*gitea.User {
|
||||
out := make([]*gitea.User, 0, len(list))
|
||||
for _, a := range list {
|
||||
if strings.TrimSpace(a.UserName) != "" {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/auth"
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// CreateLogin create an login interactive
|
||||
func CreateLogin(ctx context.Context) error {
|
||||
var (
|
||||
name, token, user, passwd, otp, scopes, sshKey, sshCertPrincipal, sshKeyFingerprint string
|
||||
insecure, sshAgent, versionCheck, helper bool
|
||||
)
|
||||
|
||||
versionCheck = true
|
||||
helper = false
|
||||
|
||||
giteaURL := "https://gitea.com"
|
||||
if err := huh.NewInput().
|
||||
Title("URL of Gitea instance: ").
|
||||
Value(&giteaURL).
|
||||
Validate(func(s string) error {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 0 {
|
||||
return fmt.Errorf("URL is required")
|
||||
}
|
||||
_, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("URL of Gitea instance: ", giteaURL)
|
||||
|
||||
giteaURL = strings.TrimSuffix(strings.TrimSpace(giteaURL), "/")
|
||||
|
||||
name, err := task.GenerateLoginName(giteaURL, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validateFunc := func(s string) error {
|
||||
if err := huh.ValidateNotEmpty()(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logins, err := config.GetLogins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, login := range logins {
|
||||
if login.Name == name {
|
||||
return fmt.Errorf("login with name '%s' already exists", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := huh.NewInput().
|
||||
Title("Name of new Login: ").
|
||||
Value(&name).
|
||||
Validate(validateFunc).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printTitleAndContent("Name of new Login: ", name)
|
||||
|
||||
loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate", "oauth"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Login with: ", loginMethod)
|
||||
|
||||
switch loginMethod {
|
||||
case "oauth":
|
||||
if err := huh.NewConfirm().
|
||||
Title("Allow Insecure connections:").
|
||||
Value(&insecure).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
|
||||
|
||||
return auth.OAuthLoginWithOptions(ctx, name, giteaURL, insecure)
|
||||
default: // token
|
||||
var hasToken bool
|
||||
if err := huh.NewConfirm().
|
||||
Title("Do you have an access token?").
|
||||
Value(&hasToken).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Do you have an access token?", strconv.FormatBool(hasToken))
|
||||
|
||||
if hasToken {
|
||||
if err := huh.NewInput().
|
||||
Title("Token:").
|
||||
Value(&token).
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Token:", token)
|
||||
} else {
|
||||
if err := huh.NewInput().
|
||||
Title("Username:").
|
||||
Value(&user).
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Username:", user)
|
||||
|
||||
if err := huh.NewInput().
|
||||
Title("Password:").
|
||||
Value(&passwd).
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Password:", "********")
|
||||
|
||||
var tokenScopes []string
|
||||
if err := huh.NewMultiSelect[string]().
|
||||
Title("Token Scopes:").
|
||||
Options(huh.NewOptions(tokenScopeOpts...)...).
|
||||
Value(&tokenScopes).
|
||||
Validate(func(s []string) error {
|
||||
if len(s) == 0 {
|
||||
return errors.New("at least one scope is required")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Token Scopes:", strings.Join(tokenScopes, "\n"))
|
||||
|
||||
scopes = strings.Join(tokenScopes, ",")
|
||||
|
||||
// Ask for OTP last so it's less likely to timeout
|
||||
if err := huh.NewInput().
|
||||
Title("OTP (if applicable):").
|
||||
Value(&otp).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("OTP (if applicable):", otp)
|
||||
}
|
||||
case "ssh-key/certificate":
|
||||
if err := huh.NewInput().
|
||||
Title("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):").
|
||||
Value(&sshKey).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):", sshKey)
|
||||
|
||||
if sshKey == "" {
|
||||
pubKeys := task.ListSSHPubkey()
|
||||
if len(pubKeys) == 0 {
|
||||
fmt.Println("No SSH keys found in ~/.ssh or ssh-agent")
|
||||
return nil
|
||||
}
|
||||
sshKey, err = promptSelect("Select ssh-key: ", pubKeys, "", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Selected ssh-key:", sshKey)
|
||||
|
||||
sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var optSettings bool
|
||||
if err := huh.NewConfirm().
|
||||
Title("Set Optional settings:").
|
||||
Value(&optSettings).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Set Optional settings:", strconv.FormatBool(optSettings))
|
||||
|
||||
if optSettings {
|
||||
if err := huh.NewInput().
|
||||
Title("SSH Key Path (leave empty for auto-discovery):").
|
||||
Value(&sshKey).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("SSH Key Path (leave empty for auto-discovery):", sshKey)
|
||||
|
||||
if err := huh.NewConfirm().
|
||||
Title("Allow Insecure connections:").
|
||||
Value(&insecure).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure))
|
||||
|
||||
if err := huh.NewConfirm().
|
||||
Title("Add git helper:").
|
||||
Value(&helper).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Add git helper:", strconv.FormatBool(helper))
|
||||
|
||||
if err := huh.NewConfirm().
|
||||
Title("Check version of Gitea instance:").
|
||||
Value(&versionCheck).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Check version of Gitea instance:", strconv.FormatBool(versionCheck))
|
||||
}
|
||||
|
||||
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)
|
||||
}
|
||||
|
||||
func parseSSHPubkeySelection(display string) (sshKey, sshCertPrincipal, sshKeyFingerprint string, sshAgent bool, err error) {
|
||||
if strings.Contains(display, "principals") {
|
||||
if sshCertPrincipal, err = regexpSubmatch(regexp.MustCompile(`.*?principals: (.*?)[,|\s]`), display); err != nil {
|
||||
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate principal from %q: %w", display, err)
|
||||
}
|
||||
if strings.HasSuffix(display, "(ssh-agent)") {
|
||||
return "", sshCertPrincipal, "", true, nil
|
||||
}
|
||||
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
|
||||
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate path from %q: %w", display, err)
|
||||
}
|
||||
return strings.TrimSuffix(sshKey, "-cert.pub"), sshCertPrincipal, "", false, nil
|
||||
}
|
||||
|
||||
if sshKeyFingerprint, err = regexpSubmatch(regexp.MustCompile(`(SHA256:.*?)\s`), display); err != nil {
|
||||
return "", "", "", false, fmt.Errorf("failed to parse SSH key fingerprint from %q: %w", display, err)
|
||||
}
|
||||
if strings.HasSuffix(display, "(ssh-agent)") {
|
||||
return "", "", sshKeyFingerprint, true, nil
|
||||
}
|
||||
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
|
||||
return "", "", "", false, fmt.Errorf("failed to parse SSH key path from %q: %w", display, err)
|
||||
}
|
||||
return strings.TrimSuffix(sshKey, ".pub"), "", sshKeyFingerprint, false, nil
|
||||
}
|
||||
|
||||
func regexpSubmatch(re *regexp.Regexp, s string) (string, error) {
|
||||
match := re.FindStringSubmatch(s)
|
||||
if len(match) < 2 {
|
||||
return "", fmt.Errorf("no match")
|
||||
}
|
||||
return match[1], nil
|
||||
}
|
||||
|
||||
var tokenScopeOpts = []string{
|
||||
string(gitea.AccessTokenScopeAll),
|
||||
string(gitea.AccessTokenScopeRepo),
|
||||
string(gitea.AccessTokenScopeRepoStatus),
|
||||
string(gitea.AccessTokenScopePublicRepo),
|
||||
string(gitea.AccessTokenScopeAdminOrg),
|
||||
string(gitea.AccessTokenScopeWriteOrg),
|
||||
string(gitea.AccessTokenScopeReadOrg),
|
||||
string(gitea.AccessTokenScopeAdminPublicKey),
|
||||
string(gitea.AccessTokenScopeWritePublicKey),
|
||||
string(gitea.AccessTokenScopeReadPublicKey),
|
||||
string(gitea.AccessTokenScopeAdminRepoHook),
|
||||
string(gitea.AccessTokenScopeWriteRepoHook),
|
||||
string(gitea.AccessTokenScopeReadRepoHook),
|
||||
string(gitea.AccessTokenScopeAdminOrgHook),
|
||||
string(gitea.AccessTokenScopeAdminUserHook),
|
||||
string(gitea.AccessTokenScopeNotification),
|
||||
string(gitea.AccessTokenScopeUser),
|
||||
string(gitea.AccessTokenScopeReadUser),
|
||||
string(gitea.AccessTokenScopeUserEmail),
|
||||
string(gitea.AccessTokenScopeUserFollow),
|
||||
string(gitea.AccessTokenScopeDeleteRepo),
|
||||
string(gitea.AccessTokenScopePackage),
|
||||
string(gitea.AccessTokenScopeWritePackage),
|
||||
string(gitea.AccessTokenScopeReadPackage),
|
||||
string(gitea.AccessTokenScopeDeletePackage),
|
||||
string(gitea.AccessTokenScopeAdminGPGKey),
|
||||
string(gitea.AccessTokenScopeWriteGPGKey),
|
||||
string(gitea.AccessTokenScopeReadGPGKey),
|
||||
string(gitea.AccessTokenScopeAdminApplication),
|
||||
string(gitea.AccessTokenScopeWriteApplication),
|
||||
string(gitea.AccessTokenScopeReadApplication),
|
||||
string(gitea.AccessTokenScopeSudo),
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSSHPubkeySelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
display string
|
||||
wantSSHKey string
|
||||
wantCertPrincipal string
|
||||
wantKeyFingerprint string
|
||||
wantSSHAgent bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "local ed25519 key",
|
||||
display: "SHA256:abc ssh-ed25519 comment (/home/user/.ssh/id_ed25519.pub)",
|
||||
wantSSHKey: "/home/user/.ssh/id_ed25519",
|
||||
wantKeyFingerprint: "SHA256:abc",
|
||||
},
|
||||
{
|
||||
name: "agent ed25519 key",
|
||||
display: "SHA256:abc ssh-ed25519 comment (ssh-agent)",
|
||||
wantKeyFingerprint: "SHA256:abc",
|
||||
wantSSHAgent: true,
|
||||
},
|
||||
{
|
||||
name: "local certificate",
|
||||
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1,user2 (/home/user/.ssh/id_ed25519-cert.pub)",
|
||||
wantSSHKey: "/home/user/.ssh/id_ed25519",
|
||||
wantCertPrincipal: "user1",
|
||||
},
|
||||
{
|
||||
name: "agent certificate",
|
||||
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1 (ssh-agent)",
|
||||
wantCertPrincipal: "user1",
|
||||
wantSSHAgent: true,
|
||||
},
|
||||
{
|
||||
name: "unexpected display",
|
||||
display: "ssh-ed25519 comment",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sshKey, certPrincipal, keyFingerprint, sshAgent, err := parseSSHPubkeySelection(tt.display)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantSSHKey, sshKey)
|
||||
assert.Equal(t, tt.wantCertPrincipal, certPrincipal)
|
||||
assert.Equal(t, tt.wantKeyFingerprint, keyFingerprint)
|
||||
assert.Equal(t, tt.wantSSHAgent, sshAgent)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// CreateMilestone interactively creates a milestone
|
||||
func CreateMilestone(requestCtx stdctx.Context, login *config.Login, owner, repo string) error {
|
||||
var title, description, deadline string
|
||||
|
||||
// owner, repo
|
||||
owner, repo, err := promptRepoSlug(owner, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Target repo:", fmt.Sprintf("%s/%s", owner, repo))
|
||||
|
||||
if err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Milestone title:").
|
||||
Validate(huh.ValidateNotEmpty()).
|
||||
Value(&title),
|
||||
huh.NewText().
|
||||
Title("Milestone description(markdown):").
|
||||
ExternalEditor(config.GetPreferences().Editor).
|
||||
EditorExtension("md").
|
||||
Value(&description),
|
||||
huh.NewInput().
|
||||
Title("Milestone deadline:").
|
||||
Placeholder("YYYY-MM-DD").
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return nil // no deadline
|
||||
}
|
||||
_, err := time.Parse("2006-01-02", s)
|
||||
return err
|
||||
}).
|
||||
Value(&deadline),
|
||||
),
|
||||
).WithTheme(theme.GetTheme()).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var deadlineTM *time.Time
|
||||
if deadline != "" {
|
||||
tm, _ := time.Parse("2006-01-02", deadline)
|
||||
deadlineTM = &tm
|
||||
}
|
||||
|
||||
return task.CreateMilestone(requestCtx, login,
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
description,
|
||||
deadlineTM,
|
||||
gitea.StateOpen)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// printTitleAndContent prints a title and content with the gitea theme
|
||||
func printTitleAndContent(title, content string) {
|
||||
hasDarkBG := theme.HasDarkBackground()
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
|
||||
Padding(0, 1)
|
||||
fmt.Print(style.Render(title), content+"\n")
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/theme"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// PromptPassword asks for a password and blocks until input was made.
|
||||
func PromptPassword(name string) (pass string, err error) {
|
||||
err = huh.NewInput().
|
||||
Title(name + " password:").
|
||||
Validate(huh.ValidateNotEmpty()).EchoMode(huh.EchoModePassword).
|
||||
Value(&pass).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run()
|
||||
return
|
||||
}
|
||||
|
||||
// promptRepoSlug interactively prompts for a Gitea repository or returns the current one
|
||||
func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err error) {
|
||||
prompt := "Target repo:"
|
||||
defaultVal := ""
|
||||
required := true
|
||||
if len(defaultOwner) != 0 && len(defaultRepo) != 0 {
|
||||
defaultVal = fmt.Sprintf("%s/%s", defaultOwner, defaultRepo)
|
||||
required = false
|
||||
}
|
||||
var repoSlug string
|
||||
|
||||
owner = defaultOwner
|
||||
repo = defaultRepo
|
||||
repoSlug = defaultVal
|
||||
|
||||
err = huh.NewInput().
|
||||
Title(prompt).
|
||||
Value(&repoSlug).
|
||||
Validate(func(str string) error {
|
||||
if !required && len(str) == 0 {
|
||||
return nil
|
||||
}
|
||||
split := strings.Split(str, "/")
|
||||
if len(split) != 2 || len(split[0]) == 0 || len(split[1]) == 0 {
|
||||
return fmt.Errorf("must follow the <owner>/<repo> syntax")
|
||||
}
|
||||
return nil
|
||||
}).WithTheme(theme.GetTheme()).Run()
|
||||
|
||||
if err == nil && len(repoSlug) != 0 {
|
||||
repoSlugSplit := strings.Split(repoSlug, "/")
|
||||
owner = repoSlugSplit[0]
|
||||
repo = repoSlugSplit[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// promptDatetime prompts for a date or datetime string.
|
||||
// Supports all formats understood by araddon/dateparse.
|
||||
func promptDatetime(prompt string) (val *time.Time, err error) {
|
||||
var date string
|
||||
if err := huh.NewInput().
|
||||
Title(prompt).
|
||||
Placeholder("YYYY-MM-DD").
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := time.Parse("2006-01-02", s)
|
||||
return err
|
||||
}).
|
||||
Value(&date).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if date == "" {
|
||||
return nil, nil // no date
|
||||
}
|
||||
t, _ := time.Parse("2006-01-02", date)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// promptSelect creates a generic multiselect prompt, with processing of custom values.
|
||||
func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) {
|
||||
opts := huh.NewOptions(makeSelectOpts(options, customVal, "")...)
|
||||
return runMultiSelect(prompt, opts, customVal)
|
||||
}
|
||||
|
||||
// promptMultiSelectWithPreselect creates a generic multiselect prompt with preselected values and processing of custom values.
|
||||
func promptMultiSelectWithPreselect(prompt string, selected []string, options []string, customVal string) ([]string, error) {
|
||||
opts := make([]huh.Option[string], 0, len(selected)+len(options)+1)
|
||||
for _, name := range selected {
|
||||
opts = append(opts, huh.NewOption(name, name).Selected(true))
|
||||
}
|
||||
opts = append(opts, huh.NewOptions(makeSelectOpts(options, customVal, "")...)...)
|
||||
|
||||
return runMultiSelect(prompt, opts, customVal)
|
||||
}
|
||||
|
||||
func runMultiSelect(prompt string, opts []huh.Option[string], customVal string) ([]string, error) {
|
||||
var selection []string
|
||||
if err := huh.NewMultiSelect[string]().
|
||||
Title(prompt).
|
||||
Options(opts...).
|
||||
Value(&selection).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return promptCustomVal(prompt, customVal, selection)
|
||||
}
|
||||
|
||||
// promptSelectV2 creates a generic select prompt
|
||||
func promptSelectV2(prompt string, options []string) (string, error) {
|
||||
if len(options) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
selection := options[0]
|
||||
if err := huh.NewSelect[string]().
|
||||
Title(prompt).
|
||||
Options(huh.NewOptions(options...)...).
|
||||
Value(&selection).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
// promptSelect creates a generic select prompt, with processing of custom values or none-option.
|
||||
func promptSelect(prompt string, options []string, customVal, noneVal, defaultVal string) (string, error) {
|
||||
var selection string
|
||||
if defaultVal == "" && noneVal != "" {
|
||||
defaultVal = noneVal
|
||||
}
|
||||
if len(options) > 0 && !slices.Contains(options, defaultVal) {
|
||||
defaultVal = options[0]
|
||||
}
|
||||
selection = defaultVal
|
||||
if err := huh.NewSelect[string]().
|
||||
Title(prompt).
|
||||
Options(huh.NewOptions(makeSelectOpts(options, customVal, noneVal)...)...).
|
||||
Value(&selection).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if noneVal != "" && selection == noneVal {
|
||||
return "", nil
|
||||
}
|
||||
if customVal != "" {
|
||||
sel, err := promptCustomVal(prompt, customVal, []string{selection})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
selection = sel[0]
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
// makeSelectOpts adds cusotmVal & noneVal to opts if set.
|
||||
func makeSelectOpts(opts []string, customVal, noneVal string) []string {
|
||||
if customVal != "" {
|
||||
opts = append(opts, customVal)
|
||||
}
|
||||
if noneVal != "" {
|
||||
opts = append(opts, noneVal)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// promptCustomVal checks if customVal is present in selection, and prompts
|
||||
// for custom input to add to the selection instead.
|
||||
func promptCustomVal(prompt, customVal string, selection []string) ([]string, error) {
|
||||
// check for custom value & prompt again with text input
|
||||
if otherIndex := utils.IndexOf(selection, customVal); otherIndex != -1 {
|
||||
var customAssignees string
|
||||
if err := huh.NewInput().
|
||||
Title(prompt).
|
||||
Description("comma separated list").
|
||||
Value(&customAssignees).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selection = append(selection[:otherIndex], selection[otherIndex+1:]...)
|
||||
selection = append(selection, strings.Split(customAssignees, ",")...)
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/modules/context"
|
||||
"gitea.dev/tea/modules/print"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// CreatePull interactively creates a PR
|
||||
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error) {
|
||||
var (
|
||||
base, head string
|
||||
allowMaintainerEdits = true
|
||||
|
||||
agit bool
|
||||
)
|
||||
|
||||
// owner, repo
|
||||
if ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// base
|
||||
if base, err = task.GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var headOwner, headBranch string
|
||||
validator := huh.ValidateNotEmpty()
|
||||
if ctx.LocalRepo != nil {
|
||||
headOwner, headBranch, err = task.GetDefaultPRHead(ctx.LocalRepo)
|
||||
if err == nil {
|
||||
validator = func(string) error { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
if err := huh.NewConfirm().
|
||||
Title("Do you want to create an agit flow pull request?").
|
||||
Value(&agit).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agit {
|
||||
var (
|
||||
topic string
|
||||
baseRemote string
|
||||
)
|
||||
|
||||
topic = headBranch
|
||||
|
||||
head = "HEAD"
|
||||
baseRemote = "origin"
|
||||
|
||||
if err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Target branch:").
|
||||
Value(&base).
|
||||
Validate(huh.ValidateNotEmpty()),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Source repo remote:").
|
||||
Value(&baseRemote),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Topic branch:").
|
||||
Value(&topic).
|
||||
Validate(validator),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Head branch:").
|
||||
Value(&head).
|
||||
Validate(validator),
|
||||
),
|
||||
).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
|
||||
if err = promptIssueProperties(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return task.CreateAgitFlowPull(
|
||||
requestCtx,
|
||||
ctx,
|
||||
baseRemote,
|
||||
head,
|
||||
base,
|
||||
topic,
|
||||
&opts,
|
||||
PromptPassword,
|
||||
)
|
||||
}
|
||||
|
||||
if err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Target branch:").
|
||||
Value(&base).
|
||||
Validate(huh.ValidateNotEmpty()),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Source repo owner:").
|
||||
Value(&headOwner),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Source branch:").
|
||||
Value(&headBranch).
|
||||
Validate(validator),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Allow maintainers to push to the base branch:").
|
||||
Value(&allowMaintainerEdits),
|
||||
),
|
||||
).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
head = task.GetHeadSpec(headOwner, headBranch, ctx.Owner)
|
||||
|
||||
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
|
||||
if err = promptIssueProperties(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pr, err := task.CreatePull(
|
||||
requestCtx,
|
||||
ctx,
|
||||
base,
|
||||
head,
|
||||
&allowMaintainerEdits,
|
||||
&opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.PullDetails(pr, nil, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/cmd/flags"
|
||||
"gitea.dev/tea/modules/context"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// MergePull interactively creates a PR
|
||||
func MergePull(requestCtx stdctx.Context, ctx *context.TeaContext) error {
|
||||
if ctx.LocalRepo == nil {
|
||||
return fmt.Errorf("pull request index is required")
|
||||
}
|
||||
|
||||
branch, _, err := ctx.LocalRepo.TeaGetCurrentBranchNameAndSHA()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idx, err := getPullIndex(requestCtx, ctx, branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{
|
||||
Style: gitea.MergeStyle(ctx.String("style")),
|
||||
Title: ctx.String("title"),
|
||||
Message: ctx.String("message"),
|
||||
})
|
||||
}
|
||||
|
||||
// getPullIndex interactively determines the PR index
|
||||
func getPullIndex(requestCtx stdctx.Context, ctx *context.TeaContext, branch string) (int64, error) {
|
||||
c := ctx.Login.Client()
|
||||
opts := gitea.ListPullRequestsOptions{
|
||||
State: gitea.StateOpen,
|
||||
ListOptions: flags.GetListOptions(ctx.Command),
|
||||
}
|
||||
selected := ""
|
||||
loadMoreOption := "PR not found? Load more PRs..."
|
||||
|
||||
// paginated fetch
|
||||
var prs []*gitea.PullRequest
|
||||
for {
|
||||
var err error
|
||||
prs, _, err = c.PullRequests.ListRepoPullRequests(requestCtx, ctx.Owner, ctx.Repo, opts)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(prs) == 0 {
|
||||
return 0, fmt.Errorf("no open PRs found")
|
||||
}
|
||||
opts.ListOptions.Page++
|
||||
prOptions := make([]string, 0)
|
||||
|
||||
// get the PR indexes where head branch is the current branch
|
||||
for _, pr := range prs {
|
||||
if pr.Head.Ref == branch {
|
||||
prOptions = append(prOptions, fmt.Sprintf("#%d: %s", pr.Index, pr.Title))
|
||||
}
|
||||
}
|
||||
|
||||
// then get the PR indexes where base branch is the current branch
|
||||
for _, pr := range prs {
|
||||
// don't add the same PR twice, so `pr.Head.Ref != branch`
|
||||
if pr.Base.Ref == branch && pr.Head.Ref != branch {
|
||||
prOptions = append(prOptions, fmt.Sprintf("#%d: %s", pr.Index, pr.Title))
|
||||
}
|
||||
}
|
||||
|
||||
prOptions = append(prOptions, loadMoreOption)
|
||||
|
||||
if err := huh.NewSelect[string]().
|
||||
Title("Select a PR to merge:").
|
||||
Options(huh.NewOptions(prOptions...)...).
|
||||
Value(&selected).
|
||||
Filtering(true).
|
||||
Run(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if selected != loadMoreOption {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// get the index from the selected option
|
||||
before, _, _ := strings.Cut(selected, ":")
|
||||
before = strings.TrimPrefix(before, "#")
|
||||
idx, err := utils.ArgToIndex(before)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return idx, nil
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interact
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/context"
|
||||
"gitea.dev/tea/modules/task"
|
||||
"gitea.dev/tea/modules/theme"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
var reviewStates = map[string]gitea.ReviewStateType{
|
||||
"approve": gitea.ReviewStateApproved,
|
||||
"comment": gitea.ReviewStateComment,
|
||||
"request changes": gitea.ReviewStateRequestChanges,
|
||||
}
|
||||
var reviewStateOptions = []string{"comment", "request changes", "approve"}
|
||||
|
||||
// ReviewPull interactively reviews a PR
|
||||
func ReviewPull(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) error {
|
||||
var state gitea.ReviewStateType
|
||||
var comment string
|
||||
var codeComments []gitea.CreatePullReviewComment
|
||||
var err error
|
||||
|
||||
// codeComments
|
||||
reviewDiff := true
|
||||
if err := huh.NewConfirm().
|
||||
Title("Review / comment the diff?").
|
||||
Value(&reviewDiff).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Review / comment the diff?", strconv.FormatBool(reviewDiff))
|
||||
|
||||
if reviewDiff {
|
||||
if codeComments, err = DoDiffReview(requestCtx, ctx, idx); err != nil {
|
||||
fmt.Printf("Error during diff review: %s\n", err)
|
||||
}
|
||||
fmt.Printf("Found %d code comments in your review\n", len(codeComments))
|
||||
}
|
||||
|
||||
// state
|
||||
var stateString string
|
||||
if err := huh.NewSelect[string]().
|
||||
Title("Your assessment:").
|
||||
Options(huh.NewOptions(reviewStateOptions...)...).
|
||||
Value(&stateString).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Your assessment:", stateString)
|
||||
|
||||
state = reviewStates[stateString]
|
||||
|
||||
// comment
|
||||
field := huh.NewText().
|
||||
Title("Concluding comment(markdown):").
|
||||
ExternalEditor(config.GetPreferences().Editor).
|
||||
EditorExtension("md").
|
||||
Value(&comment)
|
||||
if (state == gitea.ReviewStateComment && len(codeComments) == 0) || state == gitea.ReviewStateRequestChanges {
|
||||
field = field.Validate(huh.ValidateNotEmpty())
|
||||
}
|
||||
if err := huh.NewForm(huh.NewGroup(field)).WithTheme(theme.GetTheme()).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
printTitleAndContent("Concluding comment(markdown):", comment)
|
||||
|
||||
return task.CreatePullReview(requestCtx, ctx, idx, state, comment, codeComments)
|
||||
}
|
||||
|
||||
// DoDiffReview (1) fetches & saves diff in tempfile, (2) starts $VISUAL or $EDITOR to comment on diff,
|
||||
// (3) parses resulting file into code comments.
|
||||
// It doesn't really make sense to use survey.Editor() here, as we'd read the file content at least twice.
|
||||
func DoDiffReview(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64) ([]gitea.CreatePullReviewComment, error) {
|
||||
tmpFile, err := task.SavePullDiff(requestCtx, ctx, idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
if err = task.OpenFileInEditor(tmpFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return task.ParseDiffComments(tmpFile)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// ActionSecretsList prints a list of action secrets
|
||||
func ActionSecretsList(secrets []*gitea.Secret, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"Name",
|
||||
"Created",
|
||||
},
|
||||
}
|
||||
|
||||
for _, secret := range secrets {
|
||||
t.addRow(
|
||||
secret.Name,
|
||||
FormatTime(secret.Created, output != ""),
|
||||
)
|
||||
}
|
||||
|
||||
if len(secrets) == 0 {
|
||||
fmt.Printf("No secrets found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// ActionVariableDetails prints details of a specific action variable
|
||||
func ActionVariableDetails(variable *gitea.RepoActionVariable) {
|
||||
fmt.Printf("Name: %s\n", variable.Name)
|
||||
fmt.Printf("Value: %s\n", variable.Value)
|
||||
fmt.Printf("Repository ID: %d\n", variable.RepoID)
|
||||
fmt.Printf("Owner ID: %d\n", variable.OwnerID)
|
||||
}
|
||||
|
||||
// ActionVariablesList prints a list of action variables
|
||||
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"Name",
|
||||
"Value",
|
||||
"Repository ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, variable := range variables {
|
||||
// Truncate long values for table display
|
||||
value := variable.Value
|
||||
if len(value) > 50 {
|
||||
value = value[:47] + "..."
|
||||
}
|
||||
|
||||
t.addRow(
|
||||
variable.Name,
|
||||
value,
|
||||
fmt.Sprintf("%d", variable.RepoID),
|
||||
)
|
||||
}
|
||||
|
||||
if len(variables) == 0 {
|
||||
fmt.Printf("No variables found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// formatDurationMinutes formats duration in a human-readable way
|
||||
func formatDurationMinutes(started, completed time.Time) string {
|
||||
if started.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
end := completed
|
||||
if end.IsZero() {
|
||||
end = time.Now()
|
||||
}
|
||||
|
||||
duration := end.Sub(started)
|
||||
if duration < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(duration.Seconds()))
|
||||
}
|
||||
if duration < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(duration.Minutes()))
|
||||
}
|
||||
hours := int(duration.Hours())
|
||||
minutes := int(duration.Minutes()) % 60
|
||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||
}
|
||||
|
||||
// getWorkflowDisplayName returns the display title or falls back to path
|
||||
func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string {
|
||||
if run.DisplayTitle != "" {
|
||||
return run.DisplayTitle
|
||||
}
|
||||
return run.Path
|
||||
}
|
||||
|
||||
// ActionRunsList prints a list of workflow runs
|
||||
func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"ID",
|
||||
"Status",
|
||||
"Workflow",
|
||||
"Branch",
|
||||
"Event",
|
||||
"Started",
|
||||
"Duration",
|
||||
},
|
||||
}
|
||||
|
||||
machineReadable := isMachineReadable(output)
|
||||
|
||||
for _, run := range runs {
|
||||
workflowName := getWorkflowDisplayName(run)
|
||||
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
|
||||
|
||||
t.addRow(
|
||||
fmt.Sprintf("%d", run.ID),
|
||||
run.Status,
|
||||
workflowName,
|
||||
run.HeadBranch,
|
||||
run.Event,
|
||||
FormatTime(run.StartedAt, machineReadable),
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
if len(runs) == 0 {
|
||||
fmt.Printf("No workflow runs found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// ActionRunDetails prints detailed information about a workflow run
|
||||
func ActionRunDetails(run *gitea.ActionWorkflowRun) {
|
||||
workflowName := getWorkflowDisplayName(run)
|
||||
|
||||
fmt.Printf("Run ID: %d\n", run.ID)
|
||||
fmt.Printf("Run Number: %d\n", run.RunNumber)
|
||||
fmt.Printf("Status: %s\n", run.Status)
|
||||
if run.Conclusion != "" {
|
||||
fmt.Printf("Conclusion: %s\n", run.Conclusion)
|
||||
}
|
||||
fmt.Printf("Workflow: %s\n", workflowName)
|
||||
fmt.Printf("Path: %s\n", run.Path)
|
||||
fmt.Printf("Branch: %s\n", run.HeadBranch)
|
||||
fmt.Printf("Event: %s\n", run.Event)
|
||||
fmt.Printf("Head SHA: %s\n", run.HeadSha)
|
||||
fmt.Printf("Started: %s\n", FormatTime(run.StartedAt, false))
|
||||
if !run.CompletedAt.IsZero() {
|
||||
fmt.Printf("Completed: %s\n", FormatTime(run.CompletedAt, false))
|
||||
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
|
||||
fmt.Printf("Duration: %s\n", duration)
|
||||
}
|
||||
if run.RunAttempt > 1 {
|
||||
fmt.Printf("Attempt: %d\n", run.RunAttempt)
|
||||
}
|
||||
if run.Actor != nil {
|
||||
fmt.Printf("Triggered by: %s\n", run.Actor.UserName)
|
||||
}
|
||||
if run.HTMLURL != "" {
|
||||
fmt.Printf("URL: %s\n", run.HTMLURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ActionWorkflowJobsList prints a list of workflow jobs
|
||||
func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"ID",
|
||||
"Name",
|
||||
"Status",
|
||||
"Runner",
|
||||
"Started",
|
||||
"Duration",
|
||||
},
|
||||
}
|
||||
|
||||
machineReadable := isMachineReadable(output)
|
||||
|
||||
for _, job := range jobs {
|
||||
duration := formatDurationMinutes(job.StartedAt, job.CompletedAt)
|
||||
runner := job.RunnerName
|
||||
if runner == "" {
|
||||
runner = "-"
|
||||
}
|
||||
|
||||
t.addRow(
|
||||
fmt.Sprintf("%d", job.ID),
|
||||
job.Name,
|
||||
job.Status,
|
||||
runner,
|
||||
FormatTime(job.StartedAt, machineReadable),
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Printf("No jobs found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// ActionWorkflowsList prints a list of workflows from the workflow API
|
||||
func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"ID",
|
||||
"Name",
|
||||
"Path",
|
||||
"State",
|
||||
},
|
||||
}
|
||||
|
||||
for _, wf := range workflows {
|
||||
t.addRow(
|
||||
wf.ID,
|
||||
wf.Name,
|
||||
wf.Path,
|
||||
wf.State,
|
||||
)
|
||||
}
|
||||
|
||||
if len(workflows) == 0 {
|
||||
fmt.Printf("No workflows found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(1, true) // Sort by name column
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// ActionWorkflowDetails prints detailed information about a workflow
|
||||
func ActionWorkflowDetails(wf *gitea.ActionWorkflow) {
|
||||
fmt.Printf("ID: %s\n", wf.ID)
|
||||
fmt.Printf("Name: %s\n", wf.Name)
|
||||
fmt.Printf("Path: %s\n", wf.Path)
|
||||
fmt.Printf("State: %s\n", wf.State)
|
||||
if wf.HTMLURL != "" {
|
||||
fmt.Printf("URL: %s\n", wf.HTMLURL)
|
||||
}
|
||||
if wf.BadgeURL != "" {
|
||||
fmt.Printf("Badge: %s\n", wf.BadgeURL)
|
||||
}
|
||||
if !wf.CreatedAt.IsZero() {
|
||||
fmt.Printf("Created: %s\n", FormatTime(wf.CreatedAt, false))
|
||||
}
|
||||
if !wf.UpdatedAt.IsZero() {
|
||||
fmt.Printf("Updated: %s\n", FormatTime(wf.UpdatedAt, false))
|
||||
}
|
||||
}
|
||||
|
||||
// ActionWorkflowDispatchResult prints the result of a workflow dispatch
|
||||
func ActionWorkflowDispatchResult(details *gitea.RunDetails) {
|
||||
fmt.Printf("Workflow dispatched successfully\n")
|
||||
if details != nil {
|
||||
fmt.Printf("Run ID: %d\n", details.WorkflowRunID)
|
||||
if details.HTMLURL != "" {
|
||||
fmt.Printf("URL: %s\n", details.HTMLURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionRunsListEmpty(t *testing.T) {
|
||||
// Test with empty runs - should not panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionRunsList panicked with empty list: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{}, ""))
|
||||
}
|
||||
|
||||
func TestActionRunsListWithData(t *testing.T) {
|
||||
runs := []*gitea.ActionWorkflowRun{
|
||||
{
|
||||
ID: 1,
|
||||
Status: "success",
|
||||
DisplayTitle: "Test Workflow",
|
||||
HeadBranch: "main",
|
||||
Event: "push",
|
||||
StartedAt: time.Now().Add(-1 * time.Hour),
|
||||
CompletedAt: time.Now().Add(-30 * time.Minute),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Status: "in_progress",
|
||||
Path: ".gitea/workflows/test.yml",
|
||||
HeadBranch: "feature",
|
||||
Event: "pull_request",
|
||||
StartedAt: time.Now().Add(-10 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
// Test that it doesn't panic with real data
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionRunsList panicked with data: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionRunsList(runs, ""))
|
||||
}
|
||||
|
||||
func TestActionRunDetails(t *testing.T) {
|
||||
run := &gitea.ActionWorkflowRun{
|
||||
ID: 123,
|
||||
RunNumber: 42,
|
||||
Status: "success",
|
||||
Conclusion: "success",
|
||||
DisplayTitle: "Build and Test",
|
||||
Path: ".gitea/workflows/ci.yml",
|
||||
HeadBranch: "main",
|
||||
Event: "push",
|
||||
HeadSha: "abc123def456",
|
||||
StartedAt: time.Now().Add(-2 * time.Hour),
|
||||
CompletedAt: time.Now().Add(-1 * time.Hour),
|
||||
RunAttempt: 1,
|
||||
Actor: &gitea.User{
|
||||
UserName: "testuser",
|
||||
},
|
||||
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/123",
|
||||
}
|
||||
|
||||
// Test that it doesn't panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionRunDetails panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ActionRunDetails(run)
|
||||
}
|
||||
|
||||
func TestActionWorkflowJobsListEmpty(t *testing.T) {
|
||||
// Test with empty jobs - should not panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowJobsList panicked with empty list: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, ""))
|
||||
}
|
||||
|
||||
func TestActionWorkflowJobsListWithData(t *testing.T) {
|
||||
jobs := []*gitea.ActionWorkflowJob{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "build",
|
||||
Status: "success",
|
||||
RunnerName: "runner-1",
|
||||
StartedAt: time.Now().Add(-30 * time.Minute),
|
||||
CompletedAt: time.Now().Add(-20 * time.Minute),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Name: "test",
|
||||
Status: "in_progress",
|
||||
RunnerName: "runner-2",
|
||||
StartedAt: time.Now().Add(-5 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
// Test that it doesn't panic with real data
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowJobsList panicked with data: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
|
||||
}
|
||||
|
||||
func TestActionWorkflowsListEmpty(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowsList panicked with empty list: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, ""))
|
||||
}
|
||||
|
||||
func TestActionWorkflowsListWithData(t *testing.T) {
|
||||
workflows := []*gitea.ActionWorkflow{
|
||||
{
|
||||
ID: "1",
|
||||
Name: "CI",
|
||||
Path: ".gitea/workflows/ci.yml",
|
||||
State: "active",
|
||||
},
|
||||
{
|
||||
ID: "2",
|
||||
Name: "Deploy",
|
||||
Path: ".gitea/workflows/deploy.yml",
|
||||
State: "disabled_manually",
|
||||
},
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowsList panicked with data: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionWorkflowsList(workflows, ""))
|
||||
}
|
||||
|
||||
func TestActionWorkflowDetails(t *testing.T) {
|
||||
wf := &gitea.ActionWorkflow{
|
||||
ID: "1",
|
||||
Name: "CI Pipeline",
|
||||
Path: ".gitea/workflows/ci.yml",
|
||||
State: "active",
|
||||
HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml",
|
||||
BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg",
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowDetails panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ActionWorkflowDetails(wf)
|
||||
}
|
||||
|
||||
func TestActionWorkflowDispatchResult(t *testing.T) {
|
||||
details := &gitea.RunDetails{
|
||||
WorkflowRunID: 42,
|
||||
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42",
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowDispatchResult panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ActionWorkflowDispatchResult(details)
|
||||
}
|
||||
|
||||
func TestActionWorkflowDispatchResultNil(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ActionWorkflowDispatchResult(nil)
|
||||
}
|
||||
|
||||
func TestFormatDurationMinutes(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
started time.Time
|
||||
completed time.Time
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "zero started",
|
||||
started: time.Time{},
|
||||
completed: now,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "30 seconds",
|
||||
started: now.Add(-30 * time.Second),
|
||||
completed: now,
|
||||
expected: "30s",
|
||||
},
|
||||
{
|
||||
name: "5 minutes",
|
||||
started: now.Add(-5 * time.Minute),
|
||||
completed: now,
|
||||
expected: "5m",
|
||||
},
|
||||
{
|
||||
name: "in progress (no completed)",
|
||||
started: now.Add(-1 * time.Hour),
|
||||
completed: time.Time{},
|
||||
expected: "1h0m",
|
||||
},
|
||||
{
|
||||
name: "2 hours 30 minutes",
|
||||
started: now.Add(-150 * time.Minute),
|
||||
completed: now,
|
||||
expected: "2h30m",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := formatDurationMinutes(test.started, test.completed)
|
||||
if result != test.expected {
|
||||
t.Errorf("formatDurationMinutes() = %q, want %q", result, test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionSecretsListEmpty(t *testing.T) {
|
||||
// Test with empty secrets - should not panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionSecretsList panicked with empty list: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionSecretsList([]*gitea.Secret{}, ""))
|
||||
}
|
||||
|
||||
func TestActionSecretsListWithData(t *testing.T) {
|
||||
secrets := []*gitea.Secret{
|
||||
{
|
||||
Name: "TEST_SECRET_1",
|
||||
Created: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
{
|
||||
Name: "TEST_SECRET_2",
|
||||
Created: time.Now().Add(-48 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
// Test that it doesn't panic with real data
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionSecretsList panicked with data: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionSecretsList(secrets, ""))
|
||||
|
||||
// Test JSON output format to verify structure
|
||||
var buf bytes.Buffer
|
||||
testTable := table{
|
||||
headers: []string{"Name", "Created"},
|
||||
}
|
||||
|
||||
for _, secret := range secrets {
|
||||
testTable.addRow(secret.Name, FormatTime(secret.Created, true))
|
||||
}
|
||||
|
||||
require.NoError(t, testTable.fprint(&buf, "json"))
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "TEST_SECRET_1") {
|
||||
t.Error("Expected TEST_SECRET_1 in JSON output")
|
||||
}
|
||||
if !strings.Contains(output, "TEST_SECRET_2") {
|
||||
t.Error("Expected TEST_SECRET_2 in JSON output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionVariableDetails(t *testing.T) {
|
||||
variable := &gitea.RepoActionVariable{
|
||||
Name: "TEST_VARIABLE",
|
||||
Value: "test_value",
|
||||
RepoID: 123,
|
||||
OwnerID: 456,
|
||||
}
|
||||
|
||||
// Test that it doesn't panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionVariableDetails panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
ActionVariableDetails(variable)
|
||||
}
|
||||
|
||||
func TestActionVariablesListEmpty(t *testing.T) {
|
||||
// Test with empty variables - should not panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionVariablesList panicked with empty list: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{}, ""))
|
||||
}
|
||||
|
||||
func TestActionVariablesListWithData(t *testing.T) {
|
||||
variables := []*gitea.RepoActionVariable{
|
||||
{
|
||||
Name: "TEST_VARIABLE_1",
|
||||
Value: "short_value",
|
||||
RepoID: 123,
|
||||
OwnerID: 456,
|
||||
},
|
||||
{
|
||||
Name: "TEST_VARIABLE_2",
|
||||
Value: strings.Repeat("a", 60), // Long value to test truncation
|
||||
RepoID: 124,
|
||||
OwnerID: 457,
|
||||
},
|
||||
}
|
||||
|
||||
// Test that it doesn't panic with real data
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionVariablesList panicked with data: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionVariablesList(variables, ""))
|
||||
|
||||
// Test JSON output format to verify structure and truncation
|
||||
var buf bytes.Buffer
|
||||
testTable := table{
|
||||
headers: []string{"Name", "Value", "Repository ID"},
|
||||
}
|
||||
|
||||
for _, variable := range variables {
|
||||
value := variable.Value
|
||||
if len(value) > 50 {
|
||||
value = value[:47] + "..."
|
||||
}
|
||||
testTable.addRow(variable.Name, value, strconv.Itoa(int(variable.RepoID)))
|
||||
}
|
||||
|
||||
require.NoError(t, testTable.fprint(&buf, "json"))
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "TEST_VARIABLE_1") {
|
||||
t.Error("Expected TEST_VARIABLE_1 in JSON output")
|
||||
}
|
||||
if !strings.Contains(output, "TEST_VARIABLE_2") {
|
||||
t.Error("Expected TEST_VARIABLE_2 in JSON output")
|
||||
}
|
||||
|
||||
// Check that long value is truncated in our test table
|
||||
if strings.Contains(output, strings.Repeat("a", 60)) {
|
||||
t.Error("Long value should be truncated in table output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionVariablesListValueTruncation(t *testing.T) {
|
||||
variable := &gitea.RepoActionVariable{
|
||||
Name: "LONG_VALUE_VARIABLE",
|
||||
Value: strings.Repeat("abcdefghij", 10), // 100 characters
|
||||
RepoID: 123,
|
||||
OwnerID: 456,
|
||||
}
|
||||
|
||||
// Test that it doesn't panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ActionVariablesList panicked with long value: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{variable}, ""))
|
||||
|
||||
// Test the truncation logic directly
|
||||
value := variable.Value
|
||||
if len(value) > 50 {
|
||||
value = value[:47] + "..."
|
||||
}
|
||||
|
||||
if len(value) != 50 { // 47 chars + "..." = 50
|
||||
t.Errorf("Truncated value should be 50 characters, got %d", len(value))
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(value, "...") {
|
||||
t.Error("Truncated value should end with '...'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableSorting(t *testing.T) {
|
||||
// Test that the table sorting works correctly
|
||||
secrets := []*gitea.Secret{
|
||||
{Name: "Z_SECRET", Created: time.Now()},
|
||||
{Name: "A_SECRET", Created: time.Now()},
|
||||
{Name: "M_SECRET", Created: time.Now()},
|
||||
}
|
||||
|
||||
// Test the table sorting logic
|
||||
table := table{
|
||||
headers: []string{"Name", "Created"},
|
||||
}
|
||||
|
||||
for _, secret := range secrets {
|
||||
table.addRow(secret.Name, FormatTime(secret.Created, true))
|
||||
}
|
||||
|
||||
// Sort by first column (Name) in ascending order (false = ascending)
|
||||
table.sort(0, false)
|
||||
|
||||
// Check that the first row is A_SECRET after ascending sorting
|
||||
if table.values[0][0] != "A_SECRET" {
|
||||
t.Errorf("Expected first sorted value to be 'A_SECRET', got '%s'", table.values[0][0])
|
||||
}
|
||||
|
||||
// Check that the last row is Z_SECRET after ascending sorting
|
||||
if table.values[2][0] != "Z_SECRET" {
|
||||
t.Errorf("Expected last sorted value to be 'Z_SECRET', got '%s'", table.values[2][0])
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
func formatByteSize(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
return formatSize(size / 1024)
|
||||
}
|
||||
|
||||
// ReleaseAttachmentsList prints a listing of release attachments
|
||||
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Name",
|
||||
"Size",
|
||||
)
|
||||
|
||||
for _, attachment := range attachments {
|
||||
t.addRow(
|
||||
attachment.Name,
|
||||
formatByteSize(attachment.Size),
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// BranchesList prints a listing of the branches
|
||||
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) error {
|
||||
printables := make([]printable, len(branches))
|
||||
|
||||
for i, branch := range branches {
|
||||
var protection *gitea.BranchProtection
|
||||
for _, p := range protections {
|
||||
if p.BranchName == branch.Name {
|
||||
protection = p
|
||||
}
|
||||
}
|
||||
printables[i] = &printableBranch{branch, protection}
|
||||
}
|
||||
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printableBranch struct {
|
||||
branch *gitea.Branch
|
||||
protection *gitea.BranchProtection
|
||||
}
|
||||
|
||||
func (x printableBranch) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "name":
|
||||
return x.branch.Name
|
||||
case "protected":
|
||||
return fmt.Sprintf("%t", x.branch.Protected)
|
||||
case "user-can-merge":
|
||||
return fmt.Sprintf("%t", x.branch.UserCanMerge)
|
||||
case "user-can-push":
|
||||
return fmt.Sprintf("%t", x.branch.UserCanPush)
|
||||
case "protection":
|
||||
if x.protection != nil {
|
||||
approving := ""
|
||||
for _, entry := range x.protection.ApprovalsWhitelistTeams {
|
||||
approving += entry + "/"
|
||||
}
|
||||
for _, entry := range x.protection.ApprovalsWhitelistUsernames {
|
||||
approving += entry + "/"
|
||||
}
|
||||
merging := ""
|
||||
for _, entry := range x.protection.MergeWhitelistTeams {
|
||||
merging += entry + "/"
|
||||
}
|
||||
for _, entry := range x.protection.MergeWhitelistUsernames {
|
||||
merging += entry + "/"
|
||||
}
|
||||
pushing := ""
|
||||
for _, entry := range x.protection.PushWhitelistTeams {
|
||||
pushing += entry + "/"
|
||||
}
|
||||
for _, entry := range x.protection.PushWhitelistUsernames {
|
||||
pushing += entry + "/"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"- enable-push: %t\n- approving: %s\n- merging: %s\n- pushing: %s\n",
|
||||
x.protection.EnablePush, approving, merging, pushing,
|
||||
)
|
||||
}
|
||||
return "<None>"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BranchFields are all available fields to print with BranchesList()
|
||||
var BranchFields = []string{
|
||||
"name",
|
||||
"protected",
|
||||
"user-can-merge",
|
||||
"user-can-push",
|
||||
"protection",
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrintableBranchProtectionUsesSeparateWhitelists(t *testing.T) {
|
||||
protection := &gitea.BranchProtection{
|
||||
EnablePush: true,
|
||||
ApprovalsWhitelistTeams: []string{"approve-team"},
|
||||
ApprovalsWhitelistUsernames: []string{"approve-user"},
|
||||
MergeWhitelistTeams: []string{"merge-team"},
|
||||
MergeWhitelistUsernames: []string{"merge-user"},
|
||||
PushWhitelistTeams: []string{"push-team"},
|
||||
PushWhitelistUsernames: []string{"push-user"},
|
||||
}
|
||||
|
||||
result := printableBranch{
|
||||
branch: &gitea.Branch{Name: "main"},
|
||||
protection: protection,
|
||||
}.FormatField("protection", false)
|
||||
|
||||
assert.Contains(t, result, "- approving: approve-team/approve-user/")
|
||||
assert.Contains(t, result, "- merging: merge-team/merge-user/")
|
||||
assert.Contains(t, result, "- pushing: push-team/push-user/")
|
||||
assert.NotContains(t, result, "- approving: approve-team/approve-user/merge-team/")
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// Comments renders a list of comments to stdout
|
||||
func Comments(comments []*gitea.Comment) {
|
||||
var baseURL string
|
||||
if len(comments) != 0 {
|
||||
baseURL = getRepoURL(comments[0].HTMLURL)
|
||||
}
|
||||
|
||||
out := make([]string, len(comments))
|
||||
for i, c := range comments {
|
||||
out[i] = formatComment(c)
|
||||
}
|
||||
|
||||
_ = outputMarkdown(fmt.Sprintf(
|
||||
// this will become a heading by means of the first --- from a comment
|
||||
"Comments\n%s",
|
||||
strings.Join(out, "\n"),
|
||||
), baseURL)
|
||||
}
|
||||
|
||||
// CommentsList prints comments in tabular form, including IDs so they can be
|
||||
// passed to 'tea comments edit' / 'tea comments delete'.
|
||||
func CommentsList(comments []*gitea.Comment, output string) error {
|
||||
if len(comments) == 0 {
|
||||
fmt.Println("No comments found")
|
||||
return nil
|
||||
}
|
||||
|
||||
t := tableWithHeader(
|
||||
"ID",
|
||||
"Author",
|
||||
"Created",
|
||||
"Updated",
|
||||
"Body",
|
||||
)
|
||||
|
||||
for _, c := range comments {
|
||||
updated := ""
|
||||
if c.Updated.After(c.Created) {
|
||||
updated = FormatTime(c.Updated, false)
|
||||
}
|
||||
t.addRow(
|
||||
fmt.Sprintf("%d", c.ID),
|
||||
"@"+c.Poster.UserName,
|
||||
FormatTime(c.Created, false),
|
||||
updated,
|
||||
summarizeBody(c.Body),
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
func summarizeBody(body string) string {
|
||||
const max = 80
|
||||
var b strings.Builder
|
||||
for _, r := range body {
|
||||
if r == '\n' || r == '\r' {
|
||||
b.WriteByte(' ')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
if len(s) > max {
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Comment renders a comment to stdout
|
||||
func Comment(c *gitea.Comment) {
|
||||
_ = outputMarkdown(formatComment(c), getRepoURL(c.HTMLURL))
|
||||
}
|
||||
|
||||
func formatComment(c *gitea.Comment) string {
|
||||
edited := ""
|
||||
if c.Updated.After(c.Created) {
|
||||
edited = fmt.Sprintf(" *(edited on %s)*", FormatTime(c.Updated, false))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"---\n\n**@%s** wrote on %s%s:\n\n%s\n",
|
||||
c.Poster.UserName,
|
||||
FormatTime(c.Created, false),
|
||||
edited,
|
||||
c.Body,
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/muesli/termenv"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IsInteractive checks if the output is piped, but NOT if the session is run interactively..
|
||||
func IsInteractive() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// captures the repo URL part <host>/<owner>/<repo> of an url
|
||||
var repoURLRegex = regexp.MustCompile("^([[:alnum:]]+://[^/]+(?:/[[:alnum:]]+){2})/.*")
|
||||
|
||||
func getRepoURL(resourceURL string) string {
|
||||
return repoURLRegex.ReplaceAllString(resourceURL, "$1/")
|
||||
}
|
||||
|
||||
// formatSize get kb in int and return string
|
||||
func formatSize(kb int64) string {
|
||||
if kb < 1024 {
|
||||
return fmt.Sprintf("%d KB", kb)
|
||||
}
|
||||
mb := kb / 1024
|
||||
if mb < 1024 {
|
||||
return fmt.Sprintf("%d MB", mb)
|
||||
}
|
||||
gb := mb / 1024
|
||||
if gb < 1024 {
|
||||
return fmt.Sprintf("%d GB", gb)
|
||||
}
|
||||
return fmt.Sprintf("%d TB", gb/1024)
|
||||
}
|
||||
|
||||
// FormatTime provides a string for the given time value.
|
||||
// If machineReadable is set, a UTC RFC3339 string is returned,
|
||||
// otherwise a simplified string in local time is used.
|
||||
func FormatTime(t time.Time, machineReadable bool) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
if machineReadable {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Local")
|
||||
if err != nil {
|
||||
return t.Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
return t.In(location).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func formatDuration(seconds int64, outputType string) string {
|
||||
if isMachineReadable(outputType) {
|
||||
return fmt.Sprint(seconds)
|
||||
}
|
||||
return time.Duration(1e9 * seconds).String()
|
||||
}
|
||||
|
||||
func formatLabel(label *gitea.Label, allowColor bool, text string) string {
|
||||
colorProfile := termenv.Ascii
|
||||
if allowColor {
|
||||
colorProfile = termenv.EnvColorProfile()
|
||||
}
|
||||
if len(text) == 0 {
|
||||
text = label.Name
|
||||
}
|
||||
styled := termenv.String(text)
|
||||
styled = styled.Foreground(colorProfile.Color("#" + label.Color))
|
||||
return fmt.Sprint(styled)
|
||||
}
|
||||
|
||||
func formatPermission(p *gitea.Permission) string {
|
||||
if p.Admin {
|
||||
return "admin"
|
||||
} else if p.Push {
|
||||
return "write"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
func formatUserName(u *gitea.User) string {
|
||||
if len(u.FullName) == 0 {
|
||||
return u.UserName
|
||||
}
|
||||
return u.FullName
|
||||
}
|
||||
|
||||
func formatBoolean(b bool, allowIcons bool) string {
|
||||
if !allowIcons {
|
||||
return fmt.Sprintf("%v", b)
|
||||
}
|
||||
|
||||
styled := "✔"
|
||||
if !b {
|
||||
styled = "✖"
|
||||
}
|
||||
|
||||
return styled
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/enescakir/emoji"
|
||||
)
|
||||
|
||||
// IssueDetails print an issue rendered to stdout
|
||||
func IssueDetails(issue *gitea.Issue, reactions []*gitea.Reaction) {
|
||||
out := fmt.Sprintf(
|
||||
"# #%d %s (%s)\n@%s created %s\n\n%s\n",
|
||||
issue.Index,
|
||||
issue.Title,
|
||||
issue.State,
|
||||
issue.Poster.UserName,
|
||||
FormatTime(issue.Created, false),
|
||||
issue.Body,
|
||||
)
|
||||
|
||||
if len(reactions) > 0 {
|
||||
out += fmt.Sprintf("\n---\n\n%s\n", formatReactions(reactions))
|
||||
}
|
||||
|
||||
_ = outputMarkdown(out, getRepoURL(issue.HTMLURL))
|
||||
}
|
||||
|
||||
func formatReactions(reactions []*gitea.Reaction) string {
|
||||
reactionCounts := make(map[string]uint16)
|
||||
for _, r := range reactions {
|
||||
reactionCounts[r.Reaction]++
|
||||
}
|
||||
|
||||
reactionStrings := make([]string, 0, len(reactionCounts))
|
||||
for reaction, count := range reactionCounts {
|
||||
reactionStrings = append(reactionStrings, fmt.Sprintf("%dx :%s:", count, reaction))
|
||||
}
|
||||
|
||||
return emoji.Parse(strings.Join(reactionStrings, " | "))
|
||||
}
|
||||
|
||||
// IssuesPullsList prints a listing of issues & pulls
|
||||
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) error {
|
||||
return printIssues(issues, output, fields)
|
||||
}
|
||||
|
||||
// IssueFields are all available fields to print with IssuesList()
|
||||
var IssueFields = []string{
|
||||
"index",
|
||||
"state",
|
||||
"kind",
|
||||
"author",
|
||||
"author-id",
|
||||
"url",
|
||||
|
||||
"title",
|
||||
"body",
|
||||
|
||||
"created",
|
||||
"updated",
|
||||
"deadline",
|
||||
|
||||
"assignees",
|
||||
"milestone",
|
||||
"labels",
|
||||
"comments",
|
||||
"owner",
|
||||
"repo",
|
||||
}
|
||||
|
||||
func printIssues(issues []*gitea.Issue, output string, fields []string) error {
|
||||
labelMap := map[int64]string{}
|
||||
printables := make([]printable, len(issues))
|
||||
machineReadable := isMachineReadable(output)
|
||||
|
||||
for i, x := range issues {
|
||||
// pre-serialize labels for performance
|
||||
for _, label := range x.Labels {
|
||||
if _, ok := labelMap[label.ID]; !ok {
|
||||
labelMap[label.ID] = formatLabel(label, !machineReadable, "")
|
||||
}
|
||||
}
|
||||
// store items with printable interface
|
||||
printables[i] = &printableIssue{x, &labelMap}
|
||||
}
|
||||
|
||||
t := tableFromItems(fields, printables, machineReadable)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printableIssue struct {
|
||||
*gitea.Issue
|
||||
formattedLabels *map[int64]string
|
||||
}
|
||||
|
||||
func (x printableIssue) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "index":
|
||||
return fmt.Sprintf("%d", x.Index)
|
||||
case "state":
|
||||
return string(x.State)
|
||||
case "kind":
|
||||
if x.PullRequest != nil {
|
||||
return "Pull"
|
||||
}
|
||||
return "Issue"
|
||||
case "author":
|
||||
return formatUserName(x.Poster)
|
||||
case "author-id":
|
||||
return x.Poster.UserName
|
||||
case "url":
|
||||
return x.HTMLURL
|
||||
case "title":
|
||||
return x.Title
|
||||
case "body":
|
||||
return x.Body
|
||||
case "created":
|
||||
return FormatTime(x.Created, machineReadable)
|
||||
case "updated":
|
||||
return FormatTime(x.Updated, machineReadable)
|
||||
case "deadline":
|
||||
if x.Deadline == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatTime(*x.Deadline, machineReadable)
|
||||
case "milestone":
|
||||
if x.Milestone != nil {
|
||||
return x.Milestone.Title
|
||||
}
|
||||
return ""
|
||||
case "labels":
|
||||
labels := make([]string, len(x.Labels))
|
||||
for i, l := range x.Labels {
|
||||
labels[i] = (*x.formattedLabels)[l.ID]
|
||||
}
|
||||
return strings.Join(labels, " ")
|
||||
case "assignees":
|
||||
assignees := make([]string, len(x.Assignees))
|
||||
for i, a := range x.Assignees {
|
||||
assignees[i] = formatUserName(a)
|
||||
}
|
||||
return strings.Join(assignees, " ")
|
||||
case "comments":
|
||||
return fmt.Sprintf("%d", x.Comments)
|
||||
case "owner":
|
||||
return x.Repository.Owner
|
||||
case "repo":
|
||||
return x.Repository.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// LabelsList prints a listing of labels
|
||||
func LabelsList(labels []*gitea.Label, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Index",
|
||||
"Color",
|
||||
"Name",
|
||||
"Description",
|
||||
"Level",
|
||||
)
|
||||
|
||||
for _, label := range labels {
|
||||
level := "Repository"
|
||||
if strings.Contains(label.URL, "/orgs/") {
|
||||
level = "Organization"
|
||||
}
|
||||
t.addRow(
|
||||
strconv.FormatInt(label.ID, 10),
|
||||
formatLabel(label, !isMachineReadable(output), label.Color),
|
||||
label.Name,
|
||||
label.Description,
|
||||
level,
|
||||
)
|
||||
}
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
)
|
||||
|
||||
// LoginDetails print login entry to stdout
|
||||
func LoginDetails(login *config.Login) {
|
||||
in := fmt.Sprintf("# %s\n\n[@%s](%s/%s)\n",
|
||||
login.Name,
|
||||
login.User,
|
||||
strings.TrimSuffix(login.URL, "/"),
|
||||
login.User,
|
||||
)
|
||||
if len(login.SSHKey) != 0 {
|
||||
in += fmt.Sprintf("\nSSH Key: '%s' via %s\n",
|
||||
login.SSHKey,
|
||||
login.SSHHost,
|
||||
)
|
||||
}
|
||||
in += fmt.Sprintf("\nCreated: %s", time.Unix(login.Created, 0).Format(time.RFC822))
|
||||
|
||||
_ = outputMarkdown(in, "")
|
||||
}
|
||||
|
||||
// LoginsList prints a listing of logins
|
||||
func LoginsList(logins []config.Login, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Name",
|
||||
"URL",
|
||||
"SSHHost",
|
||||
"User",
|
||||
"Default",
|
||||
)
|
||||
|
||||
for _, l := range logins {
|
||||
t.addRow(
|
||||
l.Name,
|
||||
l.URL,
|
||||
l.GetSSHHost(),
|
||||
l.User,
|
||||
fmt.Sprint(l.Default),
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginStatus contains the authentication status of a single configured login.
|
||||
type LoginStatus struct {
|
||||
Name string
|
||||
URL string
|
||||
User string
|
||||
Valid bool
|
||||
AuthMethod string
|
||||
TokenExpiry time.Time
|
||||
Helper bool
|
||||
Default bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// LoginStatusFields are the available fields to print with LoginStatuses.
|
||||
var LoginStatusFields = []string{
|
||||
"name",
|
||||
"url",
|
||||
"user",
|
||||
"valid",
|
||||
"auth_method",
|
||||
"token_expiry",
|
||||
"helper",
|
||||
"default",
|
||||
}
|
||||
|
||||
// LoginStatuses prints authentication status for one or more logins.
|
||||
func LoginStatuses(statuses []LoginStatus, output string) error {
|
||||
if output != "" {
|
||||
printables := make([]printable, len(statuses))
|
||||
for i := range statuses {
|
||||
printables[i] = statuses[i]
|
||||
}
|
||||
t := tableFromItems(LoginStatusFields, printables, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
if len(statuses) == 0 {
|
||||
fmt.Println("No logins configured.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, status := range statuses {
|
||||
if i > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
printLoginStatusReport(status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printLoginStatusReport(status LoginStatus) {
|
||||
name := status.Name
|
||||
if status.Default {
|
||||
name += " (default)"
|
||||
}
|
||||
fmt.Println(name)
|
||||
|
||||
if status.Valid {
|
||||
line := " ✔ Logged in to " + status.URL
|
||||
if status.User != "" {
|
||||
line += " as " + status.User
|
||||
}
|
||||
fmt.Println(line)
|
||||
|
||||
tokenLine := " ✔ Token is valid"
|
||||
if status.AuthMethod != "" {
|
||||
tokenLine += " (" + status.AuthMethod
|
||||
if !status.TokenExpiry.IsZero() {
|
||||
tokenLine += ", " + formatTokenExpiry(status.TokenExpiry)
|
||||
}
|
||||
tokenLine += ")"
|
||||
}
|
||||
fmt.Println(tokenLine)
|
||||
} else {
|
||||
message := status.Error
|
||||
if message == "" {
|
||||
message = "Login failed"
|
||||
}
|
||||
fmt.Println(" ✗ " + message)
|
||||
}
|
||||
|
||||
if status.Helper {
|
||||
fmt.Println(" ✔ Git credential helper configured")
|
||||
} else {
|
||||
fmt.Println(" ✗ Git credential helper not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func formatExpiryDuration(t time.Time) string {
|
||||
d := time.Until(t)
|
||||
if d < 0 {
|
||||
return "expired"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return "in less than a minute"
|
||||
}
|
||||
return "in " + strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
|
||||
}
|
||||
|
||||
func formatTokenExpiry(t time.Time) string {
|
||||
if t.Before(time.Now()) {
|
||||
return "expired"
|
||||
}
|
||||
return "expires " + formatExpiryDuration(t)
|
||||
}
|
||||
|
||||
// FormatField implements the printable interface for LoginStatus.
|
||||
func (s LoginStatus) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "name":
|
||||
return s.Name
|
||||
case "url":
|
||||
return s.URL
|
||||
case "user":
|
||||
return s.User
|
||||
case "valid":
|
||||
return formatBoolean(s.Valid, !machineReadable)
|
||||
case "auth_method":
|
||||
return s.AuthMethod
|
||||
case "token_expiry":
|
||||
if s.TokenExpiry.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if machineReadable {
|
||||
return FormatTime(s.TokenExpiry, true)
|
||||
}
|
||||
return formatExpiryDuration(s.TokenExpiry)
|
||||
case "helper":
|
||||
return formatBoolean(s.Helper, !machineReadable)
|
||||
case "default":
|
||||
return formatBoolean(s.Default, !machineReadable)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoginStatusFormatField(t *testing.T) {
|
||||
status := LoginStatus{
|
||||
Name: "gitea",
|
||||
URL: "https://gitea.com",
|
||||
User: "alice",
|
||||
Valid: true,
|
||||
AuthMethod: "oauth",
|
||||
TokenExpiry: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC),
|
||||
Helper: true,
|
||||
Default: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, "gitea", status.FormatField("name", false))
|
||||
assert.Equal(t, "https://gitea.com", status.FormatField("url", false))
|
||||
assert.Equal(t, "alice", status.FormatField("user", false))
|
||||
assert.Equal(t, "true", status.FormatField("valid", true))
|
||||
assert.Equal(t, "✔", status.FormatField("valid", false))
|
||||
assert.Equal(t, "oauth", status.FormatField("auth_method", true))
|
||||
assert.Equal(t, "2026-08-27T12:00:00Z", status.FormatField("token_expiry", true))
|
||||
assert.Equal(t, "true", status.FormatField("helper", true))
|
||||
assert.Equal(t, "✔", status.FormatField("default", false))
|
||||
}
|
||||
|
||||
func TestFormatExpiryDurationExpired(t *testing.T) {
|
||||
assert.Equal(t, "expired", formatExpiryDuration(time.Now().Add(-time.Hour)))
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/glamour/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// outputMarkdown prints markdown to stdout, formatted for terminals.
|
||||
// If the input could not be parsed, it is printed unformatted, the error
|
||||
// is returned anyway.
|
||||
func outputMarkdown(markdown string, baseURL string) error {
|
||||
var renderer *glamour.TermRenderer
|
||||
var err error
|
||||
if IsInteractive() {
|
||||
renderer, err = glamour.NewTermRenderer(
|
||||
glamour.WithBaseURL(baseURL),
|
||||
glamour.WithPreservedNewLines(),
|
||||
glamour.WithWordWrap(getWordWrap()),
|
||||
)
|
||||
} else {
|
||||
renderer, err = glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("notty"),
|
||||
glamour.WithBaseURL(baseURL),
|
||||
glamour.WithPreservedNewLines(),
|
||||
glamour.WithWordWrap(getWordWrap()),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Print(markdown)
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := renderer.Render(markdown)
|
||||
if err != nil {
|
||||
fmt.Print(markdown)
|
||||
return err
|
||||
}
|
||||
fmt.Print(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stolen from https://github.com/charmbracelet/glow/blob/e9d728c/main.go#L152-L165
|
||||
func getWordWrap() int {
|
||||
fd := int(os.Stdout.Fd())
|
||||
width := 80
|
||||
if term.IsTerminal(fd) {
|
||||
if w, _, err := term.GetSize(fd); err == nil {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
if width > 120 {
|
||||
width = 120
|
||||
}
|
||||
return width
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// MilestoneDetails print an milestone formatted to stdout
|
||||
func MilestoneDetails(milestone *gitea.Milestone) {
|
||||
fmt.Printf("%s\n",
|
||||
milestone.Title,
|
||||
)
|
||||
if len(milestone.Description) != 0 {
|
||||
outputMarkdown(milestone.Description, "")
|
||||
}
|
||||
if milestone.Deadline != nil && !milestone.Deadline.IsZero() {
|
||||
fmt.Printf("\nDeadline: %s\n", FormatTime(*milestone.Deadline, false))
|
||||
}
|
||||
}
|
||||
|
||||
// MilestonesList prints a listing of milestones
|
||||
func MilestonesList(news []*gitea.Milestone, output string, fields []string) error {
|
||||
printables := make([]printable, len(news))
|
||||
for i, x := range news {
|
||||
printables[i] = &printableMilestone{x}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
t.sort(0, true)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// MilestoneFields are all available fields to print with MilestonesList
|
||||
var MilestoneFields = []string{
|
||||
"title",
|
||||
"state",
|
||||
"items_open",
|
||||
"items_closed",
|
||||
"items",
|
||||
"duedate",
|
||||
"description",
|
||||
"created",
|
||||
"updated",
|
||||
"closed",
|
||||
"id",
|
||||
}
|
||||
|
||||
type printableMilestone struct {
|
||||
*gitea.Milestone
|
||||
}
|
||||
|
||||
func (m printableMilestone) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "title":
|
||||
return m.Title
|
||||
case "state":
|
||||
return string(m.State)
|
||||
case "items_open":
|
||||
return fmt.Sprintf("%d", m.OpenIssues)
|
||||
case "items_closed":
|
||||
return fmt.Sprintf("%d", m.ClosedIssues)
|
||||
case "items":
|
||||
return fmt.Sprintf("%d/%d", m.OpenIssues, m.ClosedIssues)
|
||||
case "duedate":
|
||||
if m.Deadline != nil && !m.Deadline.IsZero() {
|
||||
return FormatTime(*m.Deadline, machineReadable)
|
||||
}
|
||||
case "id":
|
||||
return fmt.Sprintf("%d", m.ID)
|
||||
case "description":
|
||||
return m.Description
|
||||
case "created":
|
||||
return FormatTime(m.Created, machineReadable)
|
||||
case "updated":
|
||||
if m.Updated != nil {
|
||||
return FormatTime(*m.Updated, machineReadable)
|
||||
}
|
||||
case "closed":
|
||||
if m.Closed != nil {
|
||||
return FormatTime(*m.Closed, machineReadable)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// NotificationsList prints a listing of notification threads
|
||||
func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) error {
|
||||
printables := make([]printable, len(news))
|
||||
for i, x := range news {
|
||||
printables[i] = &printableNotification{x}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// NotificationFields are all available fields to print with NotificationsList
|
||||
var NotificationFields = []string{
|
||||
"id",
|
||||
"status",
|
||||
"updated",
|
||||
|
||||
// these are about the notification subject
|
||||
"index",
|
||||
"type",
|
||||
"state",
|
||||
"title",
|
||||
"repository",
|
||||
}
|
||||
|
||||
type printableNotification struct {
|
||||
*gitea.NotificationThread
|
||||
}
|
||||
|
||||
func (n printableNotification) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "id":
|
||||
return fmt.Sprintf("%d", n.ID)
|
||||
|
||||
case "status":
|
||||
status := "read"
|
||||
if n.Pinned {
|
||||
status = "pinned"
|
||||
} else if n.Unread {
|
||||
status = "unread"
|
||||
}
|
||||
return status
|
||||
|
||||
case "updated":
|
||||
return FormatTime(n.UpdatedAt, machineReadable)
|
||||
|
||||
case "index":
|
||||
var index string
|
||||
if n.Subject.Type == "Issue" || n.Subject.Type == "Pull" {
|
||||
index = n.Subject.URL
|
||||
urlParts := strings.Split(n.Subject.URL, "/")
|
||||
if len(urlParts) != 0 {
|
||||
index = urlParts[len(urlParts)-1]
|
||||
}
|
||||
}
|
||||
return index
|
||||
|
||||
case "type":
|
||||
return string(n.Subject.Type)
|
||||
|
||||
case "state":
|
||||
return string(n.Subject.State)
|
||||
|
||||
case "title":
|
||||
return n.Subject.Title
|
||||
|
||||
case "repo", "repository":
|
||||
return n.Repository.FullName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// OrganizationDetails prints details of an org with formatting
|
||||
func OrganizationDetails(org *gitea.Organization) {
|
||||
_ = outputMarkdown(fmt.Sprintf(
|
||||
"# %s\n%s\n\n- Visibility: %s\n- Location: %s\n- Website: %s\n",
|
||||
org.UserName,
|
||||
org.Description,
|
||||
org.Visibility,
|
||||
org.Location,
|
||||
org.Website,
|
||||
), "")
|
||||
}
|
||||
|
||||
// OrganizationsList prints a listing of the organizations
|
||||
func OrganizationsList(organizations []*gitea.Organization, output string) error {
|
||||
if len(organizations) == 0 {
|
||||
fmt.Println("No organizations found")
|
||||
return nil
|
||||
}
|
||||
|
||||
t := tableWithHeader(
|
||||
"Name",
|
||||
"FullName",
|
||||
"Website",
|
||||
"Location",
|
||||
"Description",
|
||||
)
|
||||
|
||||
for _, org := range organizations {
|
||||
t.addRow(
|
||||
org.UserName,
|
||||
org.FullName,
|
||||
org.Website,
|
||||
org.Location,
|
||||
org.Description,
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
var ciStatusSymbols = map[gitea.StatusState]string{
|
||||
gitea.StatusSuccess: "✓ ",
|
||||
gitea.StatusPending: "⏳ ",
|
||||
gitea.StatusWarning: "⚠ ",
|
||||
gitea.StatusError: "✘ ",
|
||||
gitea.StatusFailure: "❌ ",
|
||||
}
|
||||
|
||||
// PullDetails print an pull rendered to stdout
|
||||
func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *gitea.CombinedStatus) {
|
||||
base := pr.Base.Name
|
||||
head := formatPRHead(pr)
|
||||
state := formatPRState(pr)
|
||||
|
||||
out := fmt.Sprintf(
|
||||
"# #%d %s (%s)\n@%s created %s\t**%s** <- **%s**\n\n%s\n\n",
|
||||
pr.Index,
|
||||
pr.Title,
|
||||
state,
|
||||
pr.Poster.UserName,
|
||||
FormatTime(*pr.Created, false),
|
||||
base,
|
||||
head,
|
||||
pr.Body,
|
||||
)
|
||||
|
||||
if ciStatus != nil || len(reviews) != 0 || pr.State == gitea.StateOpen {
|
||||
out += "---\n"
|
||||
}
|
||||
|
||||
out += formatReviews(pr, reviews)
|
||||
|
||||
if ciStatus != nil && len(ciStatus.Statuses) != 0 {
|
||||
out += "- CI:\n"
|
||||
for _, s := range ciStatus.Statuses {
|
||||
symbol := ciStatusSymbols[s.State]
|
||||
if s.TargetURL != "" {
|
||||
out += fmt.Sprintf(" - %s[**%s**](%s)", symbol, s.Context, s.TargetURL)
|
||||
} else {
|
||||
out += fmt.Sprintf(" - %s**%s**", symbol, s.Context)
|
||||
}
|
||||
if s.Description != "" {
|
||||
out += fmt.Sprintf(": %s", s.Description)
|
||||
}
|
||||
out += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
if pr.State == gitea.StateOpen {
|
||||
switch {
|
||||
case pr.Mergeable:
|
||||
out += "- No Conflicts\n"
|
||||
case pr.Draft:
|
||||
out += "- Draft (not mergeable until marked ready)\n"
|
||||
default:
|
||||
out += "- **Conflicting files**\n"
|
||||
}
|
||||
}
|
||||
|
||||
if pr.AllowMaintainerEdit {
|
||||
out += "- Maintainers are allowed to edit\n"
|
||||
}
|
||||
|
||||
if pr.HTMLURL != "" {
|
||||
out = strings.TrimRight(out, "\n") + "\n\n" + pr.HTMLURL + "\n"
|
||||
}
|
||||
|
||||
outputMarkdown(out, getRepoURL(pr.HTMLURL))
|
||||
}
|
||||
|
||||
func formatPRHead(pr *gitea.PullRequest) string {
|
||||
head := pr.Head.Name
|
||||
if pr.Head.RepoID != pr.Base.RepoID {
|
||||
if pr.Head.Repository != nil {
|
||||
head = pr.Head.Repository.Owner.UserName + ":" + head
|
||||
} else {
|
||||
head = "delete:" + head
|
||||
}
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
func formatPRState(pr *gitea.PullRequest) string {
|
||||
if pr.Merged != nil {
|
||||
return "merged"
|
||||
}
|
||||
return string(pr.State)
|
||||
}
|
||||
|
||||
func formatCIStatus(ci *gitea.CombinedStatus, machineReadable bool) string {
|
||||
if ci == nil || len(ci.Statuses) == 0 {
|
||||
return ""
|
||||
}
|
||||
if machineReadable {
|
||||
return string(ci.State)
|
||||
}
|
||||
items := make([]string, 0, len(ci.Statuses))
|
||||
for _, s := range ci.Statuses {
|
||||
items = append(items, fmt.Sprintf("%s%s", ciStatusSymbols[s.State], s.Context))
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
|
||||
result := ""
|
||||
if len(reviews) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// deduplicate reviews by user (via review time & userID),
|
||||
reviewByUserOrTeam := make(map[string]*gitea.PullReview)
|
||||
for _, review := range reviews {
|
||||
switch review.State {
|
||||
case gitea.ReviewStateApproved,
|
||||
gitea.ReviewStateRequestChanges,
|
||||
gitea.ReviewStateRequestReview:
|
||||
if review.Reviewer != nil {
|
||||
if r, ok := reviewByUserOrTeam[fmt.Sprintf("user_%d", review.Reviewer.ID)]; !ok || review.Submitted.After(r.Submitted) {
|
||||
reviewByUserOrTeam[fmt.Sprintf("user_%d", review.Reviewer.ID)] = review
|
||||
}
|
||||
} else if review.ReviewerTeam != nil {
|
||||
if r, ok := reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)]; !ok || review.Submitted.After(r.Submitted) {
|
||||
reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)] = review
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// group reviews by type
|
||||
reviewByState := make(map[gitea.ReviewStateType][]string)
|
||||
for _, r := range reviewByUserOrTeam {
|
||||
if r.Reviewer != nil {
|
||||
reviewByState[r.State] = append(reviewByState[r.State],
|
||||
r.Reviewer.UserName,
|
||||
)
|
||||
} else if r.ReviewerTeam != nil {
|
||||
// only pulls to orgs can have team reviews
|
||||
org := pr.Base.Repository.Owner
|
||||
reviewByState[r.State] = append(reviewByState[r.State],
|
||||
fmt.Sprintf("%s/%s", org.UserName, r.ReviewerTeam.Name),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// stringify
|
||||
for state, user := range reviewByState {
|
||||
result += fmt.Sprintf("- %s by @%s\n", state, strings.Join(user, ", @"))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// PullsList prints a listing of pulls
|
||||
func PullsList(prs []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
|
||||
return printPulls(prs, output, fields, ciStatuses)
|
||||
}
|
||||
|
||||
// PullFields are all available fields to print with PullsList()
|
||||
var PullFields = []string{
|
||||
"index",
|
||||
"state",
|
||||
"author",
|
||||
"author-id",
|
||||
"url",
|
||||
|
||||
"title",
|
||||
"body",
|
||||
|
||||
"mergeable",
|
||||
"base",
|
||||
"base-commit",
|
||||
"head",
|
||||
"diff",
|
||||
"patch",
|
||||
|
||||
"created",
|
||||
"updated",
|
||||
"deadline",
|
||||
|
||||
"assignees",
|
||||
"milestone",
|
||||
"labels",
|
||||
"comments",
|
||||
"ci",
|
||||
}
|
||||
|
||||
func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
|
||||
labelMap := map[int64]string{}
|
||||
printables := make([]printable, len(pulls))
|
||||
machineReadable := isMachineReadable(output)
|
||||
|
||||
for i, x := range pulls {
|
||||
// pre-serialize labels for performance
|
||||
for _, label := range x.Labels {
|
||||
if _, ok := labelMap[label.ID]; !ok {
|
||||
labelMap[label.ID] = formatLabel(label, !machineReadable, "")
|
||||
}
|
||||
}
|
||||
// store items with printable interface
|
||||
printables[i] = &printablePull{x, &labelMap, &ciStatuses}
|
||||
}
|
||||
|
||||
t := tableFromItems(fields, printables, machineReadable)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printablePull struct {
|
||||
*gitea.PullRequest
|
||||
formattedLabels *map[int64]string
|
||||
ciStatuses *map[int64]*gitea.CombinedStatus
|
||||
}
|
||||
|
||||
func (x printablePull) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "index":
|
||||
return fmt.Sprintf("%d", x.Index)
|
||||
case "state":
|
||||
return formatPRState(x.PullRequest)
|
||||
case "author":
|
||||
return formatUserName(x.Poster)
|
||||
case "author-id":
|
||||
return x.Poster.UserName
|
||||
case "url":
|
||||
return x.HTMLURL
|
||||
case "title":
|
||||
return x.Title
|
||||
case "body":
|
||||
return x.Body
|
||||
case "created":
|
||||
return FormatTime(*x.Created, machineReadable)
|
||||
case "updated":
|
||||
return FormatTime(*x.Updated, machineReadable)
|
||||
case "deadline":
|
||||
if x.Deadline == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatTime(*x.Deadline, machineReadable)
|
||||
case "milestone":
|
||||
if x.Milestone != nil {
|
||||
return x.Milestone.Title
|
||||
}
|
||||
return ""
|
||||
case "labels":
|
||||
labels := make([]string, len(x.Labels))
|
||||
for i, l := range x.Labels {
|
||||
labels[i] = (*x.formattedLabels)[l.ID]
|
||||
}
|
||||
return strings.Join(labels, " ")
|
||||
case "assignees":
|
||||
assignees := make([]string, len(x.Assignees))
|
||||
for i, a := range x.Assignees {
|
||||
assignees[i] = formatUserName(a)
|
||||
}
|
||||
return strings.Join(assignees, " ")
|
||||
case "comments":
|
||||
return fmt.Sprintf("%d", x.Comments)
|
||||
case "mergeable":
|
||||
isMergeable := x.Mergeable && x.State == gitea.StateOpen
|
||||
return formatBoolean(isMergeable, !machineReadable)
|
||||
case "base":
|
||||
return x.Base.Ref
|
||||
case "base-commit":
|
||||
return x.MergeBase
|
||||
case "head":
|
||||
return formatPRHead(x.PullRequest)
|
||||
case "diff":
|
||||
return x.DiffURL
|
||||
case "patch":
|
||||
return x.PatchURL
|
||||
case "ci":
|
||||
if x.ciStatuses != nil {
|
||||
if ci, ok := (*x.ciStatuses)[x.Index]; ok {
|
||||
return formatCIStatus(ci, machineReadable)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// PullReviewCommentFields are all available fields to print with PullReviewCommentsList()
|
||||
var PullReviewCommentFields = []string{
|
||||
"id",
|
||||
"body",
|
||||
"reviewer",
|
||||
"path",
|
||||
"line",
|
||||
"resolver",
|
||||
"created",
|
||||
"updated",
|
||||
"url",
|
||||
}
|
||||
|
||||
// PullReviewCommentsList prints a listing of pull review comments
|
||||
func PullReviewCommentsList(comments []*gitea.PullReviewComment, output string, fields []string) error {
|
||||
printables := make([]printable, len(comments))
|
||||
for i, c := range comments {
|
||||
printables[i] = &printablePullReviewComment{c}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printablePullReviewComment struct {
|
||||
*gitea.PullReviewComment
|
||||
}
|
||||
|
||||
func (x printablePullReviewComment) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "id":
|
||||
return fmt.Sprintf("%d", x.ID)
|
||||
case "body":
|
||||
return x.Body
|
||||
case "reviewer":
|
||||
if x.Reviewer != nil {
|
||||
return formatUserName(x.Reviewer)
|
||||
}
|
||||
return ""
|
||||
case "path":
|
||||
return x.Path
|
||||
case "line":
|
||||
if x.LineNum != 0 {
|
||||
return fmt.Sprintf("%d", x.LineNum)
|
||||
}
|
||||
if x.OldLineNum != 0 {
|
||||
return fmt.Sprintf("%d", x.OldLineNum)
|
||||
}
|
||||
return ""
|
||||
case "resolver":
|
||||
if x.Resolver != nil {
|
||||
return formatUserName(x.Resolver)
|
||||
}
|
||||
return ""
|
||||
case "created":
|
||||
return FormatTime(x.Created, machineReadable)
|
||||
case "updated":
|
||||
return FormatTime(x.Updated, machineReadable)
|
||||
case "url":
|
||||
return x.HTMLURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestPR(index int64, title string) *gitea.PullRequest {
|
||||
now := time.Now()
|
||||
return &gitea.PullRequest{
|
||||
Index: index,
|
||||
Title: title,
|
||||
State: gitea.StateOpen,
|
||||
Poster: &gitea.User{UserName: "testuser"},
|
||||
Head: &gitea.PRBranchInfo{Ref: "branch", Name: "branch"},
|
||||
Base: &gitea.PRBranchInfo{Ref: "main", Name: "main"},
|
||||
Created: &now,
|
||||
Updated: &now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCIStatusNil(t *testing.T) {
|
||||
assert.Equal(t, "", formatCIStatus(nil, false))
|
||||
assert.Equal(t, "", formatCIStatus(nil, true))
|
||||
}
|
||||
|
||||
func TestFormatCIStatusEmpty(t *testing.T) {
|
||||
ci := &gitea.CombinedStatus{Statuses: []*gitea.Status{}}
|
||||
assert.Equal(t, "", formatCIStatus(ci, false))
|
||||
assert.Equal(t, "", formatCIStatus(ci, true))
|
||||
}
|
||||
|
||||
func TestFormatCIStatusMachineReadable(t *testing.T) {
|
||||
ci := &gitea.CombinedStatus{
|
||||
State: gitea.StatusSuccess,
|
||||
Statuses: []*gitea.Status{
|
||||
{State: gitea.StatusSuccess, Context: "lint"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "success", formatCIStatus(ci, true))
|
||||
|
||||
ci.State = gitea.StatusPending
|
||||
ci.Statuses = []*gitea.Status{
|
||||
{State: gitea.StatusPending, Context: "build"},
|
||||
}
|
||||
assert.Equal(t, "pending", formatCIStatus(ci, true))
|
||||
}
|
||||
|
||||
func TestFormatCIStatusSingle(t *testing.T) {
|
||||
ci := &gitea.CombinedStatus{
|
||||
State: gitea.StatusSuccess,
|
||||
Statuses: []*gitea.Status{
|
||||
{State: gitea.StatusSuccess, Context: "lint"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "✓ lint", formatCIStatus(ci, false))
|
||||
}
|
||||
|
||||
func TestFormatCIStatusMultiple(t *testing.T) {
|
||||
ci := &gitea.CombinedStatus{
|
||||
State: gitea.StatusFailure,
|
||||
Statuses: []*gitea.Status{
|
||||
{State: gitea.StatusSuccess, Context: "lint"},
|
||||
{State: gitea.StatusPending, Context: "build"},
|
||||
{State: gitea.StatusFailure, Context: "test"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "✓ lint, ⏳ build, ❌ test", formatCIStatus(ci, false))
|
||||
}
|
||||
|
||||
func TestFormatCIStatusAllStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
state gitea.StatusState
|
||||
context string
|
||||
expected string
|
||||
}{
|
||||
{gitea.StatusSuccess, "s", "✓ s"},
|
||||
{gitea.StatusPending, "p", "⏳ p"},
|
||||
{gitea.StatusWarning, "w", "⚠ w"},
|
||||
{gitea.StatusError, "e", "✘ e"},
|
||||
{gitea.StatusFailure, "f", "❌ f"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ci := &gitea.CombinedStatus{
|
||||
State: tt.state,
|
||||
Statuses: []*gitea.Status{{State: tt.state, Context: tt.context}},
|
||||
}
|
||||
assert.Equal(t, tt.expected, formatCIStatus(ci, false), "state: %s", tt.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullDetailsIncludesHTMLURL(t *testing.T) {
|
||||
pr := newTestPR(8, "Crm wip")
|
||||
pr.HTMLURL = "https://gitea.example.com/owner/repo/pulls/8"
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
PullDetails(pr, nil, nil)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, pr.HTMLURL)
|
||||
}
|
||||
|
||||
func TestPullsListWithCIField(t *testing.T) {
|
||||
prs := []*gitea.PullRequest{
|
||||
newTestPR(1, "feat: add feature"),
|
||||
newTestPR(2, "fix: bug fix"),
|
||||
}
|
||||
|
||||
ciStatuses := map[int64]*gitea.CombinedStatus{
|
||||
1: {
|
||||
State: gitea.StatusSuccess,
|
||||
Statuses: []*gitea.Status{
|
||||
{State: gitea.StatusSuccess, Context: "ci/build"},
|
||||
},
|
||||
},
|
||||
2: {
|
||||
State: gitea.StatusFailure,
|
||||
Statuses: []*gitea.Status{
|
||||
{State: gitea.StatusFailure, Context: "ci/test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tbl := tableFromItems(
|
||||
[]string{"index", "ci"},
|
||||
[]printable{
|
||||
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
|
||||
&printablePull{prs[1], &map[int64]string{}, &ciStatuses},
|
||||
},
|
||||
true,
|
||||
)
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var result []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "1", result[0]["index"])
|
||||
assert.Equal(t, "success", result[0]["ci"])
|
||||
assert.Equal(t, "2", result[1]["index"])
|
||||
assert.Equal(t, "failure", result[1]["ci"])
|
||||
}
|
||||
|
||||
func TestPullsListCIFieldEmpty(t *testing.T) {
|
||||
prs := []*gitea.PullRequest{newTestPR(1, "no ci")}
|
||||
ciStatuses := map[int64]*gitea.CombinedStatus{}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tbl := tableFromItems(
|
||||
[]string{"index", "ci"},
|
||||
[]printable{
|
||||
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
|
||||
},
|
||||
true,
|
||||
)
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var result []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "", result[0]["ci"])
|
||||
}
|
||||
|
||||
func TestPullsListNilCIStatusesWithCIField(t *testing.T) {
|
||||
prs := []*gitea.PullRequest{newTestPR(1, "nil ci")}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tbl := tableFromItems(
|
||||
[]string{"index", "ci"},
|
||||
[]printable{
|
||||
&printablePull{prs[0], &map[int64]string{}, nil},
|
||||
},
|
||||
true,
|
||||
)
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var result []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "", result[0]["ci"])
|
||||
}
|
||||
|
||||
func TestPullsListNoCIFieldNoPanic(t *testing.T) {
|
||||
prs := []*gitea.PullRequest{newTestPR(1, "test")}
|
||||
require.NoError(t, PullsList(prs, "", []string{"index", "title"}, nil))
|
||||
}
|
||||
|
||||
func TestPullFieldsContainsCI(t *testing.T) {
|
||||
assert.True(t, slices.Contains(PullFields, "ci"), "PullFields should contain 'ci'")
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// ReleasesList prints a listing of releases
|
||||
func ReleasesList(releases []*gitea.Release, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Tag-Name",
|
||||
"Title",
|
||||
"Published At",
|
||||
"Status",
|
||||
"Tar/Zip URL",
|
||||
)
|
||||
|
||||
for _, release := range releases {
|
||||
status := "released"
|
||||
if release.IsDraft {
|
||||
status = "draft"
|
||||
} else if release.IsPrerelease {
|
||||
status = "prerelease"
|
||||
}
|
||||
t.addRow(
|
||||
release.TagName,
|
||||
release.Title,
|
||||
FormatTime(release.PublishedAt, isMachineReadable(output)),
|
||||
status,
|
||||
release.TarURL+"\n"+release.ZipURL,
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// ReposList prints a listing of the repos
|
||||
func ReposList(repos []*gitea.Repository, output string, fields []string) error {
|
||||
printables := make([]printable, len(repos))
|
||||
for i, r := range repos {
|
||||
printables[i] = &printableRepo{r}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// RepoDetails print an repo formatted to stdout
|
||||
func RepoDetails(repo *gitea.Repository, topics []string) {
|
||||
title := "# " + repo.FullName
|
||||
if repo.Mirror {
|
||||
title += " (mirror)"
|
||||
}
|
||||
if repo.Fork {
|
||||
title += " (fork)"
|
||||
}
|
||||
if repo.Archived {
|
||||
title += " (archived)"
|
||||
}
|
||||
if repo.Empty {
|
||||
title += " (empty)"
|
||||
}
|
||||
title += "\n"
|
||||
|
||||
var desc string
|
||||
if len(repo.Description) != 0 {
|
||||
desc = fmt.Sprintf("*%s*\n\n", repo.Description)
|
||||
}
|
||||
|
||||
stats := fmt.Sprintf(
|
||||
"Issues: %d, Stars: %d, Forks: %d, Size: %s\n",
|
||||
repo.OpenIssues,
|
||||
repo.Stars,
|
||||
repo.Forks,
|
||||
formatSize(int64(repo.Size)),
|
||||
)
|
||||
|
||||
// NOTE: for mirrors, this is the time the mirror was last fetched..
|
||||
updated := fmt.Sprintf(
|
||||
"Updated: %s (%s ago)\n",
|
||||
repo.Updated.Format("2006-01-02 15:04"),
|
||||
time.Since(repo.Updated).Truncate(time.Minute),
|
||||
)
|
||||
|
||||
urls := fmt.Sprintf(
|
||||
"- Browse:\t%s\n- Clone:\t%s\n",
|
||||
repo.HTMLURL,
|
||||
repo.SSHURL,
|
||||
)
|
||||
if len(repo.Website) != 0 {
|
||||
urls += fmt.Sprintf("- Web:\t%s\n", repo.Website)
|
||||
}
|
||||
|
||||
perm := fmt.Sprintf(
|
||||
"- Permission:\t%s\n",
|
||||
formatPermission(repo.Permissions),
|
||||
)
|
||||
|
||||
var tops string
|
||||
if len(topics) != 0 {
|
||||
tops = fmt.Sprintf("- Topics:\t%s\n", strings.Join(topics, ", "))
|
||||
}
|
||||
|
||||
outputMarkdown(fmt.Sprintf(
|
||||
"%s%s\n%s\n%s%s%s%s",
|
||||
title,
|
||||
desc,
|
||||
stats,
|
||||
updated,
|
||||
urls,
|
||||
perm,
|
||||
tops,
|
||||
), repo.HTMLURL)
|
||||
}
|
||||
|
||||
// RepoFields are the available fields to print with ReposList()
|
||||
var RepoFields = []string{
|
||||
"description",
|
||||
"forks",
|
||||
"id",
|
||||
"name",
|
||||
"owner",
|
||||
"stars",
|
||||
"ssh",
|
||||
"updated",
|
||||
"url",
|
||||
"permission",
|
||||
"type",
|
||||
}
|
||||
|
||||
type printableRepo struct{ *gitea.Repository }
|
||||
|
||||
func (x printableRepo) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "description":
|
||||
return x.Description
|
||||
case "forks":
|
||||
return fmt.Sprintf("%d", x.Forks)
|
||||
case "id":
|
||||
return fmt.Sprintf("%d", x.ID)
|
||||
case "name":
|
||||
return x.Name
|
||||
case "owner":
|
||||
return x.Owner.UserName
|
||||
case "stars":
|
||||
return fmt.Sprintf("%d", x.Stars)
|
||||
case "ssh":
|
||||
return x.SSHURL
|
||||
case "updated":
|
||||
return FormatTime(x.Updated, machineReadable)
|
||||
case "url":
|
||||
return x.HTMLURL
|
||||
case "permission":
|
||||
return formatPermission(x.Permissions)
|
||||
case "type":
|
||||
if x.Fork {
|
||||
return "fork"
|
||||
}
|
||||
if x.Mirror {
|
||||
return "mirror"
|
||||
}
|
||||
return "source"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReposListUsesNumericIDField(t *testing.T) {
|
||||
repos := []*gitea.Repository{{
|
||||
ID: 123,
|
||||
Name: "tea",
|
||||
Owner: &gitea.User{
|
||||
UserName: "gitea",
|
||||
},
|
||||
}}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tbl := tableFromItems([]string{"id", "name"}, []printable{&printableRepo{repos[0]}}, true)
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var result []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "123", result[0]["id"])
|
||||
require.Equal(t, "tea", result[0]["name"])
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// SSHKeysList prints a table of SSH public keys
|
||||
func SSHKeysList(keys []*gitea.PublicKey, output string) error {
|
||||
if len(keys) == 0 {
|
||||
fmt.Printf("No SSH keys found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
t := tableWithHeader(
|
||||
"ID",
|
||||
"Title",
|
||||
"Fingerprint",
|
||||
"KeyType",
|
||||
"ReadOnly",
|
||||
"Created",
|
||||
)
|
||||
|
||||
for _, k := range keys {
|
||||
readOnly := "false"
|
||||
if k.ReadOnly {
|
||||
readOnly = "true"
|
||||
}
|
||||
t.addRow(
|
||||
fmt.Sprintf("%d", k.ID),
|
||||
k.Title,
|
||||
k.Fingerprint,
|
||||
k.KeyType,
|
||||
readOnly,
|
||||
FormatTime(k.Created, false),
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// table provides infrastructure to easily print (sorted) lists in different formats
|
||||
type table struct {
|
||||
headers []string
|
||||
values [][]string
|
||||
sortDesc bool // used internally by sortable interface
|
||||
sortColumn uint // ↑
|
||||
}
|
||||
|
||||
// printable can be implemented for structs to put fields dynamically into a table
|
||||
type printable interface {
|
||||
FormatField(field string, machineReadable bool) string
|
||||
}
|
||||
|
||||
// high level api to print a table of items with dynamic fields
|
||||
func tableFromItems(fields []string, values []printable, machineReadable bool) table {
|
||||
t := table{headers: fields}
|
||||
for _, v := range values {
|
||||
row := make([]string, len(fields))
|
||||
for i, f := range fields {
|
||||
row[i] = v.FormatField(f, machineReadable)
|
||||
}
|
||||
t.addRowSlice(row)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func tableWithHeader(header ...string) table {
|
||||
return table{headers: header}
|
||||
}
|
||||
|
||||
// it's the callers responsibility to ensure row length is equal to header length!
|
||||
func (t *table) addRow(row ...string) {
|
||||
t.addRowSlice(row)
|
||||
}
|
||||
|
||||
// it's the callers responsibility to ensure row length is equal to header length!
|
||||
func (t *table) addRowSlice(row []string) {
|
||||
t.values = append(t.values, row)
|
||||
}
|
||||
|
||||
func (t *table) sort(column uint, desc bool) {
|
||||
t.sortColumn = column
|
||||
t.sortDesc = desc
|
||||
sort.Stable(t) // stable to allow multiple calls to sort
|
||||
}
|
||||
|
||||
// sortable interface
|
||||
func (t table) Len() int { return len(t.values) }
|
||||
func (t table) Swap(i, j int) { t.values[i], t.values[j] = t.values[j], t.values[i] }
|
||||
func (t table) Less(i, j int) bool {
|
||||
if t.sortDesc {
|
||||
i, j = j, i
|
||||
}
|
||||
return t.values[i][t.sortColumn] < t.values[j][t.sortColumn]
|
||||
}
|
||||
|
||||
func (t *table) print(output string) error {
|
||||
return t.fprint(os.Stdout, output)
|
||||
}
|
||||
|
||||
func (t *table) fprint(f io.Writer, output string) error {
|
||||
switch output {
|
||||
case "", "table":
|
||||
return outputTable(f, t.headers, t.values)
|
||||
case "csv":
|
||||
return outputDsv(f, t.headers, t.values, ',')
|
||||
case "simple":
|
||||
return outputSimple(f, t.headers, t.values)
|
||||
case "tsv":
|
||||
return outputDsv(f, t.headers, t.values, '\t')
|
||||
case "yml", "yaml":
|
||||
return outputYaml(f, t.headers, t.values)
|
||||
case "json":
|
||||
return outputJSON(f, t.headers, t.values)
|
||||
default:
|
||||
return fmt.Errorf("unknown output type %q, available types are: csv, simple, table, tsv, yaml, json", output)
|
||||
}
|
||||
}
|
||||
|
||||
// outputTable prints structured data as table
|
||||
func outputTable(f io.Writer, headers []string, values [][]string) error {
|
||||
table := tablewriter.NewWriter(f)
|
||||
if len(headers) > 0 {
|
||||
table.Header(headers)
|
||||
}
|
||||
for _, value := range values {
|
||||
if err := table.Append(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return table.Render()
|
||||
}
|
||||
|
||||
// outputSimple prints structured data as space delimited value
|
||||
func outputSimple(f io.Writer, headers []string, values [][]string) error {
|
||||
for _, value := range values {
|
||||
if _, err := fmt.Fprintln(f, strings.Join(value, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outputDsv prints structured data as delimiter separated value format.
|
||||
func outputDsv(f io.Writer, headers []string, values [][]string, delimiter rune) error {
|
||||
writer := csv.NewWriter(f)
|
||||
writer.Comma = delimiter
|
||||
if err := writer.Write(headers); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if err := writer.Write(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
// outputYaml prints structured data as yaml
|
||||
func outputYaml(f io.Writer, headers []string, values [][]string) error {
|
||||
root := &yaml.Node{Kind: yaml.SequenceNode}
|
||||
for _, value := range values {
|
||||
row := &yaml.Node{Kind: yaml.MappingNode}
|
||||
for j, val := range value {
|
||||
row.Content = append(row.Content, &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: headers[j],
|
||||
})
|
||||
|
||||
valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val}
|
||||
if _, err := strconv.ParseInt(val, 10, 64); err == nil {
|
||||
valueNode.Tag = "!!int"
|
||||
} else {
|
||||
valueNode.Tag = "!!str"
|
||||
}
|
||||
row.Content = append(row.Content, valueNode)
|
||||
}
|
||||
root.Content = append(root.Content, row)
|
||||
}
|
||||
encoder := yaml.NewEncoder(f)
|
||||
if err := encoder.Encode(root); err != nil {
|
||||
_ = encoder.Close()
|
||||
return err
|
||||
}
|
||||
return encoder.Close()
|
||||
}
|
||||
|
||||
var (
|
||||
matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
|
||||
matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
|
||||
)
|
||||
|
||||
func toSnakeCase(str string) string {
|
||||
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
|
||||
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
// orderedRow preserves header insertion order when marshaled to JSON.
|
||||
type orderedRow struct {
|
||||
keys []string
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (o orderedRow) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i, k := range o.keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, err := json.Marshal(o.values[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf.Write(key)
|
||||
buf.WriteByte(':')
|
||||
buf.Write(val)
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// outputJSON prints structured data as json, preserving header field order.
|
||||
func outputJSON(f io.Writer, headers []string, values [][]string) error {
|
||||
snakeHeaders := make([]string, len(headers))
|
||||
for i, h := range headers {
|
||||
snakeHeaders[i] = toSnakeCase(h)
|
||||
}
|
||||
rows := make([]orderedRow, 0, len(values))
|
||||
for _, value := range values {
|
||||
row := orderedRow{keys: snakeHeaders, values: make(map[string]string, len(headers))}
|
||||
for j, val := range value {
|
||||
row.values[snakeHeaders[j]] = val
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
encoder := json.NewEncoder(f)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(rows)
|
||||
}
|
||||
|
||||
func isMachineReadable(outputFormat string) bool {
|
||||
switch outputFormat {
|
||||
case "yml", "yaml", "csv", "tsv", "json":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestToSnakeCase(t *testing.T) {
|
||||
assert.EqualValues(t, "some_test_var_at2d", toSnakeCase("SomeTestVarAt2d"))
|
||||
}
|
||||
|
||||
func TestPrint(t *testing.T) {
|
||||
tData := &table{
|
||||
headers: []string{"A", "B"},
|
||||
values: [][]string{
|
||||
{"new a", "some bbbb"},
|
||||
{"AAAAA", "b2"},
|
||||
{"\"abc", "\"def"},
|
||||
{"'abc", "de'f"},
|
||||
{"\\abc", "'def\\"},
|
||||
},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
require.NoError(t, tData.fprint(buf, "json"))
|
||||
result := []struct {
|
||||
A string
|
||||
B string
|
||||
}{}
|
||||
assert.NoError(t, json.NewDecoder(buf).Decode(&result))
|
||||
|
||||
if assert.Len(t, result, 5) {
|
||||
assert.EqualValues(t, "new a", result[0].A)
|
||||
assert.EqualValues(t, "some bbbb", result[0].B)
|
||||
assert.EqualValues(t, "AAAAA", result[1].A)
|
||||
assert.EqualValues(t, "b2", result[1].B)
|
||||
assert.EqualValues(t, "\"abc", result[2].A)
|
||||
assert.EqualValues(t, "\"def", result[2].B)
|
||||
assert.EqualValues(t, "'abc", result[3].A)
|
||||
assert.EqualValues(t, "de'f", result[3].B)
|
||||
assert.EqualValues(t, "\\abc", result[4].A)
|
||||
assert.EqualValues(t, "'def\\", result[4].B)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
|
||||
require.NoError(t, tData.fprint(buf, "yaml"))
|
||||
|
||||
var yamlResult []map[string]string
|
||||
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &yamlResult))
|
||||
assert.Equal(t, []map[string]string{
|
||||
{"A": "new a", "B": "some bbbb"},
|
||||
{"A": "AAAAA", "B": "b2"},
|
||||
{"A": "\"abc", "B": "\"def"},
|
||||
{"A": "'abc", "B": "de'f"},
|
||||
{"A": "\\abc", "B": "'def\\"},
|
||||
}, yamlResult)
|
||||
}
|
||||
|
||||
func TestPrintCSVUsesEscaping(t *testing.T) {
|
||||
tData := &table{
|
||||
headers: []string{"A", "B"},
|
||||
values: [][]string{
|
||||
{"hello,world", `quote "here"`},
|
||||
{"multi\nline", "plain"},
|
||||
},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, tData.fprint(buf, "csv"))
|
||||
|
||||
reader := csv.NewReader(bytes.NewReader(buf.Bytes()))
|
||||
records, err := reader.ReadAll()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, [][]string{
|
||||
{"A", "B"},
|
||||
{"hello,world", `quote "here"`},
|
||||
{"multi\nline", "plain"},
|
||||
}, records)
|
||||
}
|
||||
|
||||
func TestPrintJSONPreservesFieldOrder(t *testing.T) {
|
||||
tData := &table{
|
||||
headers: []string{"Zebra", "Apple", "Mango"},
|
||||
values: [][]string{{"z", "a", "m"}},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, tData.fprint(buf, "json"))
|
||||
|
||||
// Keys must appear in header order (Zebra, Apple, Mango), not sorted alphabetically
|
||||
raw := buf.String()
|
||||
zebraIdx := bytes.Index([]byte(raw), []byte(`"zebra"`))
|
||||
appleIdx := bytes.Index([]byte(raw), []byte(`"apple"`))
|
||||
mangoIdx := bytes.Index([]byte(raw), []byte(`"mango"`))
|
||||
assert.Greater(t, appleIdx, zebraIdx, "apple should appear after zebra")
|
||||
assert.Greater(t, mangoIdx, appleIdx, "mango should appear after apple")
|
||||
}
|
||||
|
||||
func TestPrintUnknownOutputReturnsError(t *testing.T) {
|
||||
tData := &table{headers: []string{"A"}, values: [][]string{{"value"}}}
|
||||
|
||||
err := tData.fprint(io.Discard, "unknown")
|
||||
require.ErrorContains(t, err, `unknown output type "unknown"`)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// TrackedTimesList print list of tracked times to stdout
|
||||
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) error {
|
||||
printables := make([]printable, len(times))
|
||||
var totalDuration int64
|
||||
for i, t := range times {
|
||||
totalDuration += t.Time
|
||||
printables[i] = &printableTrackedTime{t, outputType}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(outputType))
|
||||
|
||||
if printTotal {
|
||||
total := make([]string, len(fields))
|
||||
total[0] = "TOTAL"
|
||||
total[len(fields)-1] = formatDuration(totalDuration, outputType)
|
||||
t.addRowSlice(total)
|
||||
}
|
||||
|
||||
return t.print(outputType)
|
||||
}
|
||||
|
||||
// TrackedTimeFields contains all available fields for printing of tracked times.
|
||||
var TrackedTimeFields = []string{
|
||||
"id",
|
||||
"created",
|
||||
"repo",
|
||||
"issue",
|
||||
"user",
|
||||
"duration",
|
||||
}
|
||||
|
||||
type printableTrackedTime struct {
|
||||
*gitea.TrackedTime
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
func (t printableTrackedTime) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "id":
|
||||
return fmt.Sprintf("%d", t.ID)
|
||||
case "created":
|
||||
return FormatTime(t.Created, machineReadable)
|
||||
case "repo":
|
||||
return t.Issue.Repository.FullName
|
||||
case "issue":
|
||||
return fmt.Sprintf("#%d", t.Issue.Index)
|
||||
case "user":
|
||||
return t.UserName
|
||||
case "duration":
|
||||
return formatDuration(t.Time, t.outputFormat)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// UserDetails print a formatted user to stdout
|
||||
func UserDetails(user *gitea.User) {
|
||||
title := "# " + user.UserName
|
||||
if user.IsAdmin {
|
||||
title += " (admin)"
|
||||
}
|
||||
if !user.IsActive {
|
||||
title += " (disabled)"
|
||||
}
|
||||
if user.Restricted {
|
||||
title += " (restricted)"
|
||||
}
|
||||
if user.ProhibitLogin {
|
||||
title += " (login prohibited)"
|
||||
}
|
||||
title += "\n"
|
||||
|
||||
var desc string
|
||||
if len(user.Description) != 0 {
|
||||
desc = fmt.Sprintf("*%s*\n\n", user.Description)
|
||||
}
|
||||
var website string
|
||||
if len(user.Website) != 0 {
|
||||
website = fmt.Sprintf("%s\n\n", user.Website)
|
||||
}
|
||||
|
||||
stats := fmt.Sprintf(
|
||||
"Follower Count: %d, Following Count: %d, Starred Repos: %d\n",
|
||||
user.FollowerCount,
|
||||
user.FollowingCount,
|
||||
user.StarredRepoCount,
|
||||
)
|
||||
|
||||
outputMarkdown(fmt.Sprintf(
|
||||
"%s%s\n%s\n%s",
|
||||
title,
|
||||
desc,
|
||||
website,
|
||||
stats,
|
||||
), "")
|
||||
}
|
||||
|
||||
// UserList prints a listing of the users
|
||||
func UserList(user []*gitea.User, output string, fields []string) error {
|
||||
printables := make([]printable, len(user))
|
||||
for i, u := range user {
|
||||
printables[i] = &printableUser{u}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// UserFields are the available fields to print with UserList()
|
||||
var UserFields = []string{
|
||||
"id",
|
||||
"login",
|
||||
"full_name",
|
||||
"email",
|
||||
"avatar_url",
|
||||
"language",
|
||||
"is_admin",
|
||||
"restricted",
|
||||
"prohibit_login",
|
||||
"location",
|
||||
"website",
|
||||
"description",
|
||||
"visibility",
|
||||
"activated",
|
||||
"lastlogin_at",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
type printableUser struct{ *gitea.User }
|
||||
|
||||
func (x printableUser) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "id":
|
||||
return fmt.Sprintf("%d", x.ID)
|
||||
case "login":
|
||||
if x.IsAdmin {
|
||||
return fmt.Sprintf("%s (admin)", x.UserName)
|
||||
}
|
||||
if !x.IsActive {
|
||||
return fmt.Sprintf("%s (disabled)", x.UserName)
|
||||
}
|
||||
if x.Restricted {
|
||||
return fmt.Sprintf("%s (restricted)", x.UserName)
|
||||
}
|
||||
if x.ProhibitLogin {
|
||||
return fmt.Sprintf("%s (login prohibited)", x.UserName)
|
||||
}
|
||||
return x.UserName
|
||||
case "full_name":
|
||||
return x.FullName
|
||||
case "email":
|
||||
return x.Email
|
||||
case "avatar_url":
|
||||
return x.AvatarURL
|
||||
case "language":
|
||||
return x.Language
|
||||
case "is_admin":
|
||||
return formatBoolean(x.IsAdmin, !machineReadable)
|
||||
case "restricted":
|
||||
return formatBoolean(x.Restricted, !machineReadable)
|
||||
case "prohibit_login":
|
||||
return formatBoolean(x.ProhibitLogin, !machineReadable)
|
||||
case "activated":
|
||||
return formatBoolean(x.IsActive, !machineReadable)
|
||||
case "location":
|
||||
return x.Location
|
||||
case "website":
|
||||
return x.Website
|
||||
case "description":
|
||||
return x.Description
|
||||
case "visibility":
|
||||
return string(x.Visibility)
|
||||
case "created_at":
|
||||
return FormatTime(x.Created, machineReadable)
|
||||
case "lastlogin_at":
|
||||
return FormatTime(x.LastLogin, machineReadable)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// WebhooksList prints a listing of webhooks
|
||||
func WebhooksList(hooks []*gitea.Hook, output string) error {
|
||||
t := tableWithHeader(
|
||||
"ID",
|
||||
"Type",
|
||||
"URL",
|
||||
"Events",
|
||||
"Active",
|
||||
"Updated",
|
||||
)
|
||||
|
||||
for _, hook := range hooks {
|
||||
var url string
|
||||
if hook.Config != nil {
|
||||
url = hook.Config["url"]
|
||||
}
|
||||
|
||||
events := strings.Join(hook.Events, ",")
|
||||
if len(events) > 40 {
|
||||
events = events[:37] + "..."
|
||||
}
|
||||
|
||||
active := "✓"
|
||||
if !hook.Active {
|
||||
active = "✗"
|
||||
}
|
||||
|
||||
t.addRow(
|
||||
strconv.FormatInt(hook.ID, 10),
|
||||
string(hook.Type),
|
||||
url,
|
||||
events,
|
||||
active,
|
||||
FormatTime(hook.Updated, false),
|
||||
)
|
||||
}
|
||||
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// WebhookDetails prints detailed information about a webhook
|
||||
func WebhookDetails(hook *gitea.Hook) {
|
||||
fmt.Printf("# Webhook %d\n\n", hook.ID)
|
||||
fmt.Printf("- **Type**: %s\n", hook.Type)
|
||||
fmt.Printf("- **Active**: %t\n", hook.Active)
|
||||
fmt.Printf("- **Created**: %s\n", FormatTime(hook.Created, false))
|
||||
fmt.Printf("- **Updated**: %s\n", FormatTime(hook.Updated, false))
|
||||
|
||||
if hook.Config != nil {
|
||||
fmt.Printf("- **URL**: %s\n", hook.Config["url"])
|
||||
if contentType, ok := hook.Config["content_type"]; ok {
|
||||
fmt.Printf("- **Content Type**: %s\n", contentType)
|
||||
}
|
||||
if method, ok := hook.Config["http_method"]; ok {
|
||||
fmt.Printf("- **HTTP Method**: %s\n", method)
|
||||
}
|
||||
branchFilter := hook.BranchFilter
|
||||
if branchFilter == "" {
|
||||
branchFilter = hook.Config["branch_filter"]
|
||||
}
|
||||
if branchFilter != "" {
|
||||
fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
|
||||
}
|
||||
if _, hasSecret := hook.Config["secret"]; hasSecret {
|
||||
fmt.Printf("- **Secret**: (configured)\n")
|
||||
}
|
||||
hasAuth := hook.AuthorizationHeader != ""
|
||||
if !hasAuth {
|
||||
_, hasAuth = hook.Config["authorization_header"]
|
||||
}
|
||||
if hasAuth {
|
||||
fmt.Printf("- **Authorization Header**: (configured)\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("- **Events**: %s\n", strings.Join(hook.Events, ", "))
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWebhooksList(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
hooks := []*gitea.Hook{
|
||||
{
|
||||
ID: 1,
|
||||
Type: "gitea",
|
||||
Config: map[string]string{
|
||||
"url": "https://example.com/webhook",
|
||||
},
|
||||
Events: []string{"push", "pull_request"},
|
||||
Active: true,
|
||||
Updated: now,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Type: "slack",
|
||||
Config: map[string]string{
|
||||
"url": "https://hooks.slack.com/services/xxx",
|
||||
},
|
||||
Events: []string{"push"},
|
||||
Active: false,
|
||||
Updated: now,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Type: "discord",
|
||||
Config: nil,
|
||||
Events: []string{"pull_request", "pull_request_review_approved"},
|
||||
Active: true,
|
||||
Updated: now,
|
||||
},
|
||||
}
|
||||
|
||||
// Test that function doesn't panic with various output formats
|
||||
outputFormats := []string{"table", "csv", "json", "yaml", "simple", "tsv"}
|
||||
|
||||
for _, format := range outputFormats {
|
||||
t.Run("Format_"+format, func(t *testing.T) {
|
||||
assert.NoError(t, WebhooksList(hooks, format))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhooksListEmpty(t *testing.T) {
|
||||
// Test with empty hook list
|
||||
hooks := []*gitea.Hook{}
|
||||
|
||||
assert.NoError(t, WebhooksList(hooks, "table"))
|
||||
}
|
||||
|
||||
func TestWebhooksListNil(t *testing.T) {
|
||||
// Test with nil hook list
|
||||
assert.NoError(t, WebhooksList(nil, "table"))
|
||||
}
|
||||
|
||||
func TestWebhookDetails(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hook *gitea.Hook
|
||||
}{
|
||||
{
|
||||
name: "Complete webhook",
|
||||
hook: &gitea.Hook{
|
||||
ID: 123,
|
||||
Type: "gitea",
|
||||
Config: map[string]string{
|
||||
"url": "https://example.com/webhook",
|
||||
"content_type": "json",
|
||||
"http_method": "post",
|
||||
"secret": "secret-value",
|
||||
},
|
||||
BranchFilter: "main,develop",
|
||||
AuthorizationHeader: "Bearer token123",
|
||||
Events: []string{"push", "pull_request", "issues"},
|
||||
Active: true,
|
||||
Created: now.Add(-24 * time.Hour),
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Minimal webhook",
|
||||
hook: &gitea.Hook{
|
||||
ID: 456,
|
||||
Type: "slack",
|
||||
Config: map[string]string{"url": "https://hooks.slack.com/xxx"},
|
||||
Events: []string{"push"},
|
||||
Active: false,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Webhook with nil config",
|
||||
hook: &gitea.Hook{
|
||||
ID: 789,
|
||||
Type: "discord",
|
||||
Config: nil,
|
||||
Events: []string{"pull_request"},
|
||||
Active: true,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Webhook with empty config",
|
||||
hook: &gitea.Hook{
|
||||
ID: 999,
|
||||
Type: "gitea",
|
||||
Config: map[string]string{},
|
||||
Events: []string{},
|
||||
Active: false,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
WebhookDetails(tt.hook)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookEventsTruncation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
events []string
|
||||
maxLength int
|
||||
shouldTruncate bool
|
||||
}{
|
||||
{
|
||||
name: "Short events list",
|
||||
events: []string{"push"},
|
||||
maxLength: 40,
|
||||
shouldTruncate: false,
|
||||
},
|
||||
{
|
||||
name: "Medium events list",
|
||||
events: []string{"push", "pull_request"},
|
||||
maxLength: 40,
|
||||
shouldTruncate: false,
|
||||
},
|
||||
{
|
||||
name: "Long events list",
|
||||
events: []string{"push", "pull_request", "pull_request_review_approved", "pull_request_sync", "issues"},
|
||||
maxLength: 40,
|
||||
shouldTruncate: true,
|
||||
},
|
||||
{
|
||||
name: "Very long events list",
|
||||
events: []string{"push", "pull_request", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_comment", "pull_request_assigned", "pull_request_label"},
|
||||
maxLength: 40,
|
||||
shouldTruncate: true,
|
||||
},
|
||||
{
|
||||
name: "Empty events",
|
||||
events: []string{},
|
||||
maxLength: 40,
|
||||
shouldTruncate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
eventsStr := strings.Join(tt.events, ",")
|
||||
|
||||
if len(eventsStr) > tt.maxLength {
|
||||
assert.True(t, tt.shouldTruncate, "Events string should be truncated")
|
||||
truncated := eventsStr[:tt.maxLength-3] + "..."
|
||||
assert.Contains(t, truncated, "...")
|
||||
assert.LessOrEqual(t, len(truncated), tt.maxLength)
|
||||
} else {
|
||||
assert.False(t, tt.shouldTruncate, "Events string should not be truncated")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookActiveStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
active bool
|
||||
expectedSymbol string
|
||||
}{
|
||||
{
|
||||
name: "Active webhook",
|
||||
active: true,
|
||||
expectedSymbol: "✓",
|
||||
},
|
||||
{
|
||||
name: "Inactive webhook",
|
||||
active: false,
|
||||
expectedSymbol: "✗",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
symbol := "✓"
|
||||
if !tt.active {
|
||||
symbol = "✗"
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedSymbol, symbol)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookConfigHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]string
|
||||
expectedURL string
|
||||
hasSecret bool
|
||||
hasAuthHeader bool
|
||||
}{
|
||||
{
|
||||
name: "Config with all fields",
|
||||
config: map[string]string{
|
||||
"url": "https://example.com/webhook",
|
||||
"secret": "my-secret",
|
||||
"content_type": "json",
|
||||
"http_method": "post",
|
||||
},
|
||||
expectedURL: "https://example.com/webhook",
|
||||
hasSecret: true,
|
||||
hasAuthHeader: false,
|
||||
},
|
||||
{
|
||||
name: "Config with minimal fields",
|
||||
config: map[string]string{
|
||||
"url": "https://hooks.slack.com/xxx",
|
||||
},
|
||||
expectedURL: "https://hooks.slack.com/xxx",
|
||||
hasSecret: false,
|
||||
hasAuthHeader: false,
|
||||
},
|
||||
{
|
||||
name: "Nil config",
|
||||
config: nil,
|
||||
expectedURL: "",
|
||||
hasSecret: false,
|
||||
hasAuthHeader: false,
|
||||
},
|
||||
{
|
||||
name: "Empty config",
|
||||
config: map[string]string{},
|
||||
expectedURL: "",
|
||||
hasSecret: false,
|
||||
hasAuthHeader: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var url string
|
||||
if tt.config != nil {
|
||||
url = tt.config["url"]
|
||||
}
|
||||
assert.Equal(t, tt.expectedURL, url)
|
||||
|
||||
var hasSecret, hasAuthHeader bool
|
||||
if tt.config != nil {
|
||||
_, hasSecret = tt.config["secret"]
|
||||
_, hasAuthHeader = tt.config["authorization_header"]
|
||||
}
|
||||
assert.Equal(t, tt.hasSecret, hasSecret)
|
||||
assert.Equal(t, tt.hasAuthHeader, hasAuthHeader)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTableHeaders(t *testing.T) {
|
||||
expectedHeaders := []string{
|
||||
"ID",
|
||||
"Type",
|
||||
"URL",
|
||||
"Events",
|
||||
"Active",
|
||||
"Updated",
|
||||
}
|
||||
|
||||
// Verify all headers are non-empty and unique
|
||||
headerSet := make(map[string]bool)
|
||||
for _, header := range expectedHeaders {
|
||||
assert.NotEmpty(t, header, "Header should not be empty")
|
||||
assert.False(t, headerSet[header], "Header %s should be unique", header)
|
||||
headerSet[header] = true
|
||||
}
|
||||
|
||||
assert.Len(t, expectedHeaders, 6, "Should have exactly 6 headers")
|
||||
}
|
||||
|
||||
func TestWebhookTypeValues(t *testing.T) {
|
||||
validTypes := []string{
|
||||
"gitea",
|
||||
"gogs",
|
||||
"slack",
|
||||
"discord",
|
||||
"dingtalk",
|
||||
"telegram",
|
||||
"msteams",
|
||||
"feishu",
|
||||
"wechatwork",
|
||||
"packagist",
|
||||
}
|
||||
|
||||
for _, hookType := range validTypes {
|
||||
t.Run("Type_"+hookType, func(t *testing.T) {
|
||||
assert.NotEmpty(t, hookType, "Hook type should not be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookDetailsFormatting(t *testing.T) {
|
||||
now := time.Now()
|
||||
hook := &gitea.Hook{
|
||||
ID: 123,
|
||||
Type: "gitea",
|
||||
Config: map[string]string{
|
||||
"url": "https://example.com/webhook",
|
||||
"content_type": "json",
|
||||
"http_method": "post",
|
||||
"secret": "secret-value",
|
||||
},
|
||||
BranchFilter: "main,develop",
|
||||
AuthorizationHeader: "Bearer token123",
|
||||
Events: []string{"push", "pull_request", "issues"},
|
||||
Active: true,
|
||||
Created: now.Add(-24 * time.Hour),
|
||||
Updated: now,
|
||||
}
|
||||
|
||||
// Test that all expected fields are included in details
|
||||
expectedElements := []string{
|
||||
"123", // webhook ID
|
||||
"gitea", // webhook type
|
||||
"true", // active status
|
||||
"https://example.com/webhook", // URL
|
||||
"json", // content type
|
||||
"post", // HTTP method
|
||||
"main,develop", // branch filter
|
||||
"(configured)", // secret indicator
|
||||
"(configured)", // auth header indicator
|
||||
"push, pull_request, issues", // events list
|
||||
}
|
||||
|
||||
// Verify elements exist (placeholder test)
|
||||
assert.Greater(t, len(expectedElements), 0, "Should have expected elements")
|
||||
|
||||
// This is a functional test - in practice, we'd capture output
|
||||
// For now, we verify the webhook structure contains expected data
|
||||
assert.Equal(t, int64(123), hook.ID)
|
||||
assert.Equal(t, "gitea", hook.Type)
|
||||
assert.True(t, hook.Active)
|
||||
assert.Equal(t, "https://example.com/webhook", hook.Config["url"])
|
||||
assert.Equal(t, "json", hook.Config["content_type"])
|
||||
assert.Equal(t, "post", hook.Config["http_method"])
|
||||
assert.Equal(t, "main,develop", hook.BranchFilter)
|
||||
assert.Contains(t, hook.Config, "secret")
|
||||
assert.Equal(t, "Bearer token123", hook.AuthorizationHeader)
|
||||
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// WikiPageFields are all available fields to print with WikiPagesList().
|
||||
var WikiPageFields = []string{
|
||||
"title",
|
||||
"path",
|
||||
"url",
|
||||
"sha",
|
||||
"author",
|
||||
"updated",
|
||||
"message",
|
||||
}
|
||||
|
||||
// WikiRevisionFields are all available fields to print with WikiRevisionsList().
|
||||
var WikiRevisionFields = []string{
|
||||
"sha",
|
||||
"message",
|
||||
"author",
|
||||
"date",
|
||||
}
|
||||
|
||||
// WikiPagesList prints a listing of wiki pages.
|
||||
func WikiPagesList(pages []*gitea.WikiPageMetaData, output string, fields []string) error {
|
||||
if len(pages) == 0 && !isMachineReadable(output) {
|
||||
fmt.Println("No wiki pages found")
|
||||
return nil
|
||||
}
|
||||
|
||||
t := wikiPagesTable(pages, fields, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// WikiPageDetails prints a wiki page.
|
||||
func WikiPageDetails(page *gitea.WikiPage, output string) error {
|
||||
if output == "" {
|
||||
markdown, err := renderWikiPageMarkdown(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return outputMarkdown(markdown, getRepoURL(page.HTMLURL))
|
||||
}
|
||||
|
||||
t := wikiPageDetailsTable(page, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// WikiRevisionsList prints a listing of wiki page revisions.
|
||||
func WikiRevisionsList(revisions []*gitea.WikiCommit, output string, fields []string) error {
|
||||
t := wikiRevisionsTable(revisions, fields, isMachineReadable(output))
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
func wikiPagesTable(pages []*gitea.WikiPageMetaData, fields []string, machineReadable bool) table {
|
||||
printables := make([]printable, len(pages))
|
||||
for i, page := range pages {
|
||||
printables[i] = printableWikiPageMetaData{page}
|
||||
}
|
||||
return tableFromItems(fields, printables, machineReadable)
|
||||
}
|
||||
|
||||
func wikiPageDetailsTable(page *gitea.WikiPage, machineReadable bool) table {
|
||||
content, _ := decodeWikiContent(page.ContentBase64)
|
||||
fields := []string{"title", "content", "commits", "path", "url", "sha", "author", "updated", "message"}
|
||||
return tableFromItems(fields, []printable{printableWikiPage{page: page, content: content}}, machineReadable)
|
||||
}
|
||||
|
||||
func wikiRevisionsTable(revisions []*gitea.WikiCommit, fields []string, machineReadable bool) table {
|
||||
printables := make([]printable, len(revisions))
|
||||
for i, revision := range revisions {
|
||||
printables[i] = printableWikiRevision{revision}
|
||||
}
|
||||
return tableFromItems(fields, printables, machineReadable)
|
||||
}
|
||||
|
||||
func renderWikiPageMarkdown(page *gitea.WikiPage) (string, error) {
|
||||
content, err := decodeWikiContent(page.ContentBase64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func decodeWikiContent(contentBase64 string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(contentBase64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
type printableWikiPageMetaData struct{ *gitea.WikiPageMetaData }
|
||||
|
||||
type printableWikiPage struct {
|
||||
page *gitea.WikiPage
|
||||
content string
|
||||
}
|
||||
|
||||
type printableWikiRevision struct{ *gitea.WikiCommit }
|
||||
|
||||
func (x printableWikiPageMetaData) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "title":
|
||||
return x.Title
|
||||
case "path":
|
||||
return x.SubURL
|
||||
case "url":
|
||||
return x.HTMLURL
|
||||
case "sha":
|
||||
return shortWikiCommitID(x.LastCommit)
|
||||
case "author":
|
||||
return wikiCommitAuthor(x.LastCommit)
|
||||
case "updated":
|
||||
return wikiCommitDate(x.LastCommit, machineReadable)
|
||||
case "message":
|
||||
return wikiCommitMessage(x.LastCommit)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x printableWikiPage) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "title":
|
||||
return x.page.Title
|
||||
case "content":
|
||||
return x.content
|
||||
case "commits":
|
||||
return fmt.Sprintf("%d", x.page.CommitCount)
|
||||
case "path":
|
||||
return x.page.SubURL
|
||||
case "url":
|
||||
return x.page.HTMLURL
|
||||
case "sha":
|
||||
return shortWikiCommitID(x.page.LastCommit)
|
||||
case "author":
|
||||
return wikiCommitAuthor(x.page.LastCommit)
|
||||
case "updated":
|
||||
return wikiCommitDate(x.page.LastCommit, machineReadable)
|
||||
case "message":
|
||||
return wikiCommitMessage(x.page.LastCommit)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x printableWikiRevision) FormatField(field string, machineReadable bool) string {
|
||||
switch field {
|
||||
case "sha":
|
||||
return shortWikiCommitID(x.WikiCommit)
|
||||
case "message":
|
||||
return x.Message
|
||||
case "author":
|
||||
return wikiCommitAuthor(x.WikiCommit)
|
||||
case "date":
|
||||
return wikiCommitDate(x.WikiCommit, machineReadable)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortWikiCommitID(commit *gitea.WikiCommit) string {
|
||||
if commit == nil {
|
||||
return ""
|
||||
}
|
||||
if len(commit.ID) <= 7 {
|
||||
return commit.ID
|
||||
}
|
||||
return commit.ID[:7]
|
||||
}
|
||||
|
||||
func wikiCommitAuthor(commit *gitea.WikiCommit) string {
|
||||
if commit == nil || commit.Author == nil {
|
||||
return ""
|
||||
}
|
||||
if commit.Author.Name != "" {
|
||||
return commit.Author.Name
|
||||
}
|
||||
return commit.Author.Email
|
||||
}
|
||||
|
||||
func wikiCommitDate(commit *gitea.WikiCommit, machineReadable bool) string {
|
||||
if commit == nil || commit.Author == nil || commit.Author.Date == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, commit.Author.Date)
|
||||
if err != nil {
|
||||
return commit.Author.Date
|
||||
}
|
||||
return FormatTime(parsed, machineReadable)
|
||||
}
|
||||
|
||||
func wikiCommitMessage(commit *gitea.WikiCommit) string {
|
||||
if commit == nil {
|
||||
return ""
|
||||
}
|
||||
return commit.Message
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWikiPagesTableJSON(t *testing.T) {
|
||||
pages := []*gitea.WikiPageMetaData{{
|
||||
Title: "Home",
|
||||
HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home",
|
||||
SubURL: "/octo/tea/wiki/Home",
|
||||
LastCommit: &gitea.WikiCommit{
|
||||
ID: "abcdef123456",
|
||||
Message: "update home",
|
||||
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "alice"}, Date: "2026-05-21T01:02:03Z"},
|
||||
},
|
||||
}}
|
||||
|
||||
tbl := wikiPagesTable(pages, []string{"title", "author", "updated", "sha", "url"}, true)
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var rows []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, "Home", rows[0]["title"])
|
||||
assert.Equal(t, "alice", rows[0]["author"])
|
||||
assert.Equal(t, "2026-05-21T01:02:03Z", rows[0]["updated"])
|
||||
assert.Equal(t, "abcdef1", rows[0]["sha"])
|
||||
assert.Equal(t, "https://gitea.example.com/octo/tea/wiki/Home", rows[0]["url"])
|
||||
}
|
||||
|
||||
func TestWikiRevisionsTableJSON(t *testing.T) {
|
||||
revisions := []*gitea.WikiCommit{{
|
||||
ID: "1234567890abcdef",
|
||||
Message: "second revision",
|
||||
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "bob"}, Date: "2026-05-22T03:04:05Z"},
|
||||
}}
|
||||
|
||||
tbl := wikiRevisionsTable(revisions, []string{"sha", "message", "author", "date"}, true)
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var rows []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, "1234567", rows[0]["sha"])
|
||||
assert.Equal(t, "second revision", rows[0]["message"])
|
||||
assert.Equal(t, "bob", rows[0]["author"])
|
||||
assert.Equal(t, "2026-05-22T03:04:05Z", rows[0]["date"])
|
||||
}
|
||||
|
||||
func TestRenderWikiPageMarkdownReturnsDecodedWikiContent(t *testing.T) {
|
||||
page := &gitea.WikiPage{
|
||||
Title: "Home",
|
||||
HTMLURL: "https://gitea.example.com/octo/tea/wiki/Home",
|
||||
ContentBase64: "IyBIZWxsbyB3aWtp",
|
||||
LastCommit: &gitea.WikiCommit{
|
||||
Author: &gitea.CommitUser{GitIdentity: gitea.GitIdentity{Name: "carol"}, Date: "2026-05-23T06:07:08Z"},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := renderWikiPageMarkdown(page)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "# Hello wiki\n", out)
|
||||
}
|
||||
|
||||
func TestWikiPagesListEmptyHumanOutput(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
require.NoError(t, WikiPagesList(nil, "", []string{"title"}))
|
||||
})
|
||||
|
||||
assert.Equal(t, "No wiki pages found\n", out)
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
reader, writer, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = writer
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
var outBuf bytes.Buffer
|
||||
_, _ = io.Copy(&outBuf, reader)
|
||||
done <- outBuf.String()
|
||||
}()
|
||||
|
||||
fn()
|
||||
require.NoError(t, writer.Close())
|
||||
output := <-done
|
||||
require.NoError(t, reader.Close())
|
||||
return output
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// ResolveAssigneeOpts resolves assignee names to IssueAssigneesOption. Returns nil if names is empty.
|
||||
func ResolveAssigneeOpts(names []string) *gitea.IssueAssigneesOption {
|
||||
names = cleanAssignees(names)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &gitea.IssueAssigneesOption{Assignees: names}
|
||||
}
|
||||
|
||||
// ApplyAssigneeChanges adds and removes assignees on an issue or pull request.
|
||||
func ApplyAssigneeChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueAssigneesOption) error {
|
||||
if rm != nil {
|
||||
_, _, err := client.Issues.DeleteIssueAssignees(requestCtx, owner, repo, index, *rm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove assignees: %s", err)
|
||||
}
|
||||
}
|
||||
if add != nil {
|
||||
_, _, err := client.Issues.AddIssueAssignees(requestCtx, owner, repo, index, *add)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add assignees: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanAssignees(list []string) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, a := range list {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
)
|
||||
|
||||
// CreateIssue creates an issue in the given repo and returns the created issue
|
||||
func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) {
|
||||
// title is required
|
||||
if len(opts.Title) == 0 {
|
||||
return nil, fmt.Errorf("title is required")
|
||||
}
|
||||
|
||||
issue, _, err := rlogin.Client().Issues.CreateIssue(requestCtx, repoOwner, repoName, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create issue: %s", err)
|
||||
}
|
||||
|
||||
return issue, nil
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/context"
|
||||
)
|
||||
|
||||
// EditIssueOption wraps around gitea.EditIssueOption which has bad & incosistent semantics.
|
||||
type EditIssueOption struct {
|
||||
Index int64
|
||||
Title *string
|
||||
Body *string
|
||||
Ref *string
|
||||
Milestone *string
|
||||
Deadline *time.Time
|
||||
AddLabels []string
|
||||
RemoveLabels []string
|
||||
SetAssignees []string
|
||||
AddAssignees []string
|
||||
RemoveAssignees []string
|
||||
AddReviewers []string
|
||||
RemoveReviewers []string
|
||||
}
|
||||
|
||||
// Normalizes the options into parameters that can be passed to the sdk.
|
||||
// the returned value will be nil, when no change to this part of the issue is requested.
|
||||
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, *gitea.IssueAssigneesOption, *gitea.IssueAssigneesOption, error) {
|
||||
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
addAssigneeOpts := ResolveAssigneeOpts(o.AddAssignees)
|
||||
rmAssigneeOpts := ResolveAssigneeOpts(o.RemoveAssignees)
|
||||
|
||||
issueOpts := gitea.EditIssueOption{}
|
||||
var issueOptsDirty bool
|
||||
if o.Title != nil {
|
||||
issueOpts.Title = *o.Title
|
||||
issueOptsDirty = true
|
||||
}
|
||||
if o.Body != nil {
|
||||
issueOpts.Body = o.Body
|
||||
issueOptsDirty = true
|
||||
}
|
||||
if o.Ref != nil {
|
||||
issueOpts.Ref = o.Ref
|
||||
issueOptsDirty = true
|
||||
}
|
||||
if o.Milestone != nil {
|
||||
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
issueOpts.Milestone = gitea.OptionalInt64(id)
|
||||
issueOptsDirty = true
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
issueOpts.Deadline = o.Deadline
|
||||
issueOptsDirty = true
|
||||
if o.Deadline.IsZero() {
|
||||
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
|
||||
}
|
||||
}
|
||||
o.SetAssignees = cleanAssignees(o.SetAssignees)
|
||||
if len(o.SetAssignees) != 0 {
|
||||
issueOpts.Assignees = o.SetAssignees
|
||||
issueOptsDirty = true
|
||||
}
|
||||
|
||||
if issueOptsDirty {
|
||||
return &issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
|
||||
}
|
||||
return nil, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
|
||||
}
|
||||
|
||||
// EditIssue edits an issue and returns the updated issue.
|
||||
func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.Issue, error) {
|
||||
if client == nil {
|
||||
client = ctx.Login.Client()
|
||||
}
|
||||
|
||||
issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ApplyLabelChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issue *gitea.Issue
|
||||
if issueOpts != nil {
|
||||
issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not edit issue: %s", err)
|
||||
}
|
||||
} else {
|
||||
issue, _, err = client.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get issue: %s", err)
|
||||
}
|
||||
}
|
||||
return issue, nil
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/utils"
|
||||
)
|
||||
|
||||
// ResolveLabelNames returns a list of label IDs for a given list of label names
|
||||
func ResolveLabelNames(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
|
||||
labelIDs := make([]int64, 0, len(labelNames))
|
||||
page := 1
|
||||
for {
|
||||
labels, resp, err := client.Repositories.ListRepoLabels(requestCtx, owner, repo, gitea.ListLabelsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := client.Organizations.GetOrg(requestCtx, owner); err == nil {
|
||||
orgLabels, _, err := client.Organizations.ListOrgLabels(requestCtx, owner, gitea.ListOrgLabelsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels = append(labels, orgLabels...)
|
||||
}
|
||||
|
||||
for _, l := range labels {
|
||||
if utils.Contains(labelNames, l.Name) {
|
||||
labelIDs = append(labelIDs, l.ID)
|
||||
}
|
||||
}
|
||||
if resp == nil || resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
return labelIDs, nil
|
||||
}
|
||||
|
||||
// ResolveLabelOpts resolves label names to IssueLabelsOption. Returns nil if names is empty.
|
||||
func ResolveLabelOpts(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, names []string) (*gitea.IssueLabelsOption, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids, err := ResolveLabelNames(requestCtx, client, owner, repo, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gitea.IssueLabelsOption{Labels: ids}, nil
|
||||
}
|
||||
|
||||
// ApplyLabelChanges adds and removes labels on an issue or pull request.
|
||||
func ApplyLabelChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueLabelsOption) error {
|
||||
if rm != nil {
|
||||
// NOTE: as of 1.17, there is no API to remove multiple labels at once.
|
||||
for _, id := range rm.Labels {
|
||||
_, err := client.Issues.DeleteIssueLabel(requestCtx, owner, repo, index, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove labels: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if add != nil {
|
||||
_, _, err := client.Issues.AddIssueLabels(requestCtx, owner, repo, index, *add)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add labels: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyReviewerChanges adds and removes reviewers on a pull request.
|
||||
func ApplyReviewerChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm []string) error {
|
||||
if len(rm) != 0 {
|
||||
_, err := client.PullRequests.DeleteReviewRequests(requestCtx, owner, repo, index, gitea.PullReviewRequestOptions{
|
||||
Reviewers: rm,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove reviewers: %w", err)
|
||||
}
|
||||
}
|
||||
if len(add) != 0 {
|
||||
_, err := client.PullRequests.CreateReviewRequests(requestCtx, owner, repo, index, gitea.PullReviewRequestOptions{
|
||||
Reviewers: add,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add reviewers: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveMilestoneID resolves a milestone name to its ID. Returns 0 for empty name.
|
||||
func ResolveMilestoneID(requestCtx stdctx.Context, client *gitea.Client, owner, repo, name string) (int64, error) {
|
||||
if name == "" {
|
||||
return 0, nil
|
||||
}
|
||||
ms, _, err := client.Repositories.GetMilestoneByName(requestCtx, owner, repo, name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("could not resolve milestone '%s': %w", name, err)
|
||||
}
|
||||
return ms.ID, nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// LabelsExport save list of labels to disc
|
||||
func LabelsExport(labels []*gitea.Label, path string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for _, label := range labels {
|
||||
if _, err := fmt.Fprintf(f, "#%s %s\n", label.Color, label.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
)
|
||||
|
||||
// SetupHelper add tea helper to config global
|
||||
func SetupHelper(login config.Login) (ok bool, err error) {
|
||||
// Check that the URL is not blank
|
||||
if login.URL == "" {
|
||||
return false, fmt.Errorf("invalid Gitea URL")
|
||||
}
|
||||
|
||||
// get all helper to URL in git config
|
||||
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
|
||||
var currentHelpers []byte
|
||||
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil {
|
||||
currentHelpers = []byte{}
|
||||
}
|
||||
|
||||
// Check if ared added tea helper
|
||||
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
|
||||
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add tea helper
|
||||
if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil {
|
||||
return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err)
|
||||
} else if _, err = exec.Command("git", "config", "--global", "--add", helperKey, "!tea login helper").Output(); err != nil {
|
||||
return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HasGitCredentialHelper reports whether tea is registered as a git credential
|
||||
// helper for the given login. It mirrors the global git config lookup used by
|
||||
// SetupHelper.
|
||||
func HasGitCredentialHelper(login config.Login) bool {
|
||||
if login.URL == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
|
||||
currentHelpers, err := exec.Command("git", "config", "--global", "--get-all", helperKey).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
|
||||
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CreateLogin create a login to be stored in config
|
||||
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
|
||||
// checks ...
|
||||
// ... if we have a url
|
||||
if len(giteaURL) == 0 {
|
||||
return fmt.Errorf("Gitea server URL is required")
|
||||
}
|
||||
|
||||
// ... if there already exist a login with same name
|
||||
if login, err := config.GetLoginByName(name); err != nil {
|
||||
return err
|
||||
} else if login != nil {
|
||||
return fmt.Errorf("login name '%s' has already been used", login.Name)
|
||||
}
|
||||
// ... if we already use this token
|
||||
if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) {
|
||||
login, err := config.GetLoginByToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if login != nil {
|
||||
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
|
||||
}
|
||||
}
|
||||
|
||||
serverURL, err := utils.ValidateAuthenticationMethod(
|
||||
giteaURL,
|
||||
token,
|
||||
user,
|
||||
passwd,
|
||||
sshAgent,
|
||||
sshKey,
|
||||
sshCertPrincipal,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if it's a certificate the principal doesn't matter as the user
|
||||
// has explicitly selected this private key
|
||||
if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
|
||||
sshCertPrincipal = "yes"
|
||||
}
|
||||
|
||||
login := config.Login{
|
||||
Name: name,
|
||||
URL: serverURL.String(),
|
||||
Token: token,
|
||||
Insecure: insecure,
|
||||
SSHKey: sshKey,
|
||||
SSHCertPrincipal: sshCertPrincipal,
|
||||
SSHKeyFingerprint: sshKeyFingerprint,
|
||||
SSHAgent: sshAgent,
|
||||
Created: time.Now().Unix(),
|
||||
VersionCheck: versionCheck,
|
||||
}
|
||||
|
||||
if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" {
|
||||
if login.Token, err = generateToken(ctx, login, user, passwd, otp, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
client := login.Client()
|
||||
|
||||
// Verify if authentication works and get user info
|
||||
u, _, err := client.Users.GetMyUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
login.User = u.UserName
|
||||
|
||||
if len(login.Name) == 0 {
|
||||
if login.Name, err = GenerateLoginName(giteaURL, login.User); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// we do not have a method to get SSH config from api,
|
||||
// so we just use the host
|
||||
login.SSHHost = serverURL.Host
|
||||
|
||||
if shouldFindSSHKey(sshKey, sshAgent) {
|
||||
login.SSHKey, err = findSSHKey(ctx, client)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: problem while finding a SSH key: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = config.AddLogin(&login); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
|
||||
if addHelper {
|
||||
if _, err := SetupHelper(login); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Tip: pass --git-credentials (or run 'tea login helper setup') to authenticate 'git push' and 'git clone' over HTTPS with this token.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldFindSSHKey(sshKey string, sshAgent bool) bool {
|
||||
return sshKey == "" && !sshAgent
|
||||
}
|
||||
|
||||
func shouldCheckTokenUniqueness(token string, sshAgent bool, sshKey, sshCertPrincipal, sshKeyFingerprint string) bool {
|
||||
if sshAgent || sshKey != "" || sshCertPrincipal != "" || sshKeyFingerprint != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// generateToken creates a new token when given BasicAuth credentials
|
||||
func generateToken(ctx stdctx.Context, login config.Login, user, pass, otp, scopes string) (string, error) {
|
||||
opts := []gitea.ClientOption{gitea.SetBasicAuth(user, pass)}
|
||||
if otp != "" {
|
||||
opts = append(opts, gitea.SetOTP(otp))
|
||||
}
|
||||
client := login.Client(opts...)
|
||||
|
||||
var tl []*gitea.AccessToken
|
||||
for page := 1; ; {
|
||||
page_tokens, resp, err := client.Users.ListAccessTokens(ctx, gitea.ListAccessTokensOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tl = append(tl, page_tokens...)
|
||||
if resp == nil || resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
tokenName := host + "-tea"
|
||||
|
||||
// append timestamp, if a token with this hostname already exists
|
||||
for i := range tl {
|
||||
if tl[i].Name == tokenName {
|
||||
tokenName += time.Now().Format("2006-01-02_15-04-05")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var tokenScopes []gitea.AccessTokenScope
|
||||
if len(scopes) == 0 {
|
||||
tokenScopes = []gitea.AccessTokenScope{gitea.AccessTokenScopeAll}
|
||||
} else {
|
||||
for _, scope := range strings.Split(scopes, ",") {
|
||||
tokenScopes = append(tokenScopes, gitea.AccessTokenScope(strings.TrimSpace(scope)))
|
||||
}
|
||||
}
|
||||
|
||||
t, _, err := client.Users.CreateAccessToken(ctx, gitea.CreateAccessTokenOption{
|
||||
Name: tokenName,
|
||||
Scopes: tokenScopes,
|
||||
})
|
||||
return t.Token, err
|
||||
}
|
||||
|
||||
// GenerateLoginName generates a name string based on instance URL & adds username if the result is not unique
|
||||
func GenerateLoginName(url, user string) (string, error) {
|
||||
parsedURL, err := utils.NormalizeURL(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := parsedURL.Host
|
||||
|
||||
// append user name if login name already exists
|
||||
if len(user) != 0 {
|
||||
if login, err := config.GetLoginByName(name); err != nil {
|
||||
return "", err
|
||||
} else if login != nil {
|
||||
return name + "_" + user, nil
|
||||
}
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldCheckTokenUniqueness(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
sshAgent bool
|
||||
sshKey string
|
||||
sshCertPrincipal string
|
||||
sshKeyFingerprint string
|
||||
wantCheckUniqueness bool
|
||||
}{
|
||||
{
|
||||
name: "token only",
|
||||
token: "token",
|
||||
wantCheckUniqueness: true,
|
||||
},
|
||||
{
|
||||
name: "token with ssh agent",
|
||||
token: "token",
|
||||
sshAgent: true,
|
||||
wantCheckUniqueness: false,
|
||||
},
|
||||
{
|
||||
name: "token with ssh key path",
|
||||
token: "token",
|
||||
sshKey: "~/.ssh/id_ed25519",
|
||||
wantCheckUniqueness: false,
|
||||
},
|
||||
{
|
||||
name: "token with ssh cert principal",
|
||||
token: "token",
|
||||
sshCertPrincipal: "principal",
|
||||
wantCheckUniqueness: false,
|
||||
},
|
||||
{
|
||||
name: "token with ssh key fingerprint",
|
||||
token: "token",
|
||||
sshKeyFingerprint: "SHA256:example",
|
||||
wantCheckUniqueness: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldCheckTokenUniqueness(tt.token, tt.sshAgent, tt.sshKey, tt.sshCertPrincipal, tt.sshKeyFingerprint)
|
||||
if got != tt.wantCheckUniqueness {
|
||||
t.Fatalf("expected %v, got %v", tt.wantCheckUniqueness, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldFindSSHKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sshKey string
|
||||
sshAgent bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "explicit key",
|
||||
sshKey: "~/.ssh/id_ed25519",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ssh agent",
|
||||
sshAgent: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no key configured",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldFindSSHKey(tt.sshKey, tt.sshAgent); got != tt.want {
|
||||
t.Fatalf("expected %v, got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/sdk"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"gitea.dev/tea/modules/utils"
|
||||
)
|
||||
|
||||
// ListSSHPubkey lists all the ssh keys in the ssh agent and the ~/.ssh/*.pub files
|
||||
// It returns a list of SSH keys in the format of:
|
||||
// "fingerprint keytype comment - principals: principals (ssh-agent or path to pubkey file)"
|
||||
func ListSSHPubkey() []string {
|
||||
var keys []string
|
||||
|
||||
keys = append(keys, getAgentKeys()...)
|
||||
keys = append(keys, getLocalKeys()...)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func getAgentKeys() []string {
|
||||
ag, err := gitea.GetAgent()
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
akeys, err := ag.List()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keys []string
|
||||
|
||||
for _, akey := range akeys {
|
||||
if key := parseKeys([]byte(akey.String()), "ssh-agent"); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func getLocalKeys() []string {
|
||||
var keys []string
|
||||
|
||||
// enumerate ~/.ssh/*.pub files
|
||||
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
localPubkeyPaths, err := filepath.Glob(glob)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// parse each local key with present privkey & compare fingerprints to online keys
|
||||
for _, pubkeyPath := range localPubkeyPaths {
|
||||
var pubkeyFile []byte
|
||||
pubkeyFile, err = os.ReadFile(pubkeyPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if key := parseKeys(pubkeyFile, pubkeyPath); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func parseKeys(pkinput []byte, sshPath string) string {
|
||||
pkey, comment, _, _, err := ssh.ParseAuthorizedKey(pkinput)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(pkey.Type(), "cert-v01@openssh.com") {
|
||||
principals := pkey.(*ssh.Certificate).ValidPrincipals
|
||||
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment +
|
||||
" - principals: " + strings.Join(principals, ",") + " (" + sshPath + ")"
|
||||
}
|
||||
|
||||
return ssh.FingerprintSHA256(pkey) + " " + pkey.Type() + " " + comment + " (" + sshPath + ")"
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/utils"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// findSSHKey retrieves the ssh keys registered in gitea, and tries to find
|
||||
// a matching private key in ~/.ssh/. If no match is found, path is empty.
|
||||
func findSSHKey(ctx stdctx.Context, client *gitea.Client) (string, error) {
|
||||
// get keys registered on gitea instance
|
||||
var keys []*gitea.PublicKey
|
||||
for page := 1; ; {
|
||||
page_keys, resp, err := client.Users.ListMyPublicKeys(ctx, gitea.ListPublicKeysOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
keys = append(keys, page_keys...)
|
||||
if resp == nil || resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// enumerate ~/.ssh/*.pub files
|
||||
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
localPubkeyPaths, err := filepath.Glob(glob)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// parse each local key with present privkey & compare fingerprints to online keys
|
||||
for _, pubkeyPath := range localPubkeyPaths {
|
||||
var pubkeyFile []byte
|
||||
pubkeyFile, err = os.ReadFile(pubkeyPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(string(pubkeyFile), " ")
|
||||
if len(fields) < 2 { // first word is key type, second word is key material
|
||||
continue
|
||||
}
|
||||
|
||||
var keymaterial []byte
|
||||
keymaterial, err = base64.StdEncoding.DecodeString(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var pubkey ssh.PublicKey
|
||||
pubkey, err = ssh.ParsePublicKey(keymaterial)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
privkeyPath := strings.TrimSuffix(pubkeyPath, ".pub")
|
||||
var exists bool
|
||||
exists, err = utils.FileExist(privkeyPath)
|
||||
if err != nil || !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// if pubkey fingerprints match, return path to corresponding privkey.
|
||||
fingerprint := ssh.FingerprintSHA256(pubkey)
|
||||
for _, key := range keys {
|
||||
if fingerprint == key.Fingerprint {
|
||||
return privkeyPath, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/print"
|
||||
)
|
||||
|
||||
// CheckLoginStatus verifies the stored token for a login against the server and
|
||||
// returns a printable status. Unlike config.Login.Client, refresh failures are
|
||||
// captured in the returned status instead of terminating the process.
|
||||
func CheckLoginStatus(ctx context.Context, login *config.Login) print.LoginStatus {
|
||||
status := print.LoginStatus{
|
||||
Name: login.Name,
|
||||
URL: login.URL,
|
||||
AuthMethod: loginAuthMethod(login),
|
||||
TokenExpiry: loginTokenExpiry(login),
|
||||
Helper: HasGitCredentialHelper(*login),
|
||||
Default: login.Default,
|
||||
}
|
||||
|
||||
if login.GetAccessToken() == "" {
|
||||
status.Error = "Login failed: no access token configured"
|
||||
return status
|
||||
}
|
||||
|
||||
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
status.Error = "Token refresh failed: " + strings.TrimPrefix(err.Error(), "failed to refresh token: ")
|
||||
return status
|
||||
}
|
||||
|
||||
// A successful refresh updates the token in the secure store, so re-read the
|
||||
// expiry for the status line.
|
||||
status.TokenExpiry = loginTokenExpiry(login)
|
||||
|
||||
user, _, err := login.ClientWithoutRefresh().Users.GetMyUserInfo(ctx)
|
||||
if err != nil {
|
||||
status.Error = fmt.Sprintf("Login failed: %s", err)
|
||||
return status
|
||||
}
|
||||
|
||||
status.Valid = true
|
||||
status.User = user.UserName
|
||||
return status
|
||||
}
|
||||
|
||||
func loginAuthMethod(login *config.Login) string {
|
||||
if login.IsOAuth() {
|
||||
return config.AuthMethodOAuth
|
||||
}
|
||||
return "token"
|
||||
}
|
||||
|
||||
func loginTokenExpiry(login *config.Login) time.Time {
|
||||
expiry := login.GetTokenExpiry()
|
||||
if expiry.Equal(time.Unix(0, 0)) {
|
||||
return time.Time{}
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckLoginStatus(t *testing.T) {
|
||||
// Keep helper detection isolated from the developer's real git config.
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), ".gitconfig"))
|
||||
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/user", r.URL.Path)
|
||||
assert.Equal(t, "token secret-token", r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":1,"login":"alice"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
status := CheckLoginStatus(context.Background(), &config.Login{
|
||||
Name: "test",
|
||||
URL: server.URL,
|
||||
Token: "secret-token",
|
||||
VersionCheck: false,
|
||||
})
|
||||
|
||||
assert.True(t, status.Valid)
|
||||
assert.Empty(t, status.Error)
|
||||
assert.Equal(t, "test", status.Name)
|
||||
assert.Equal(t, server.URL, status.URL)
|
||||
assert.Equal(t, "alice", status.User)
|
||||
assert.Equal(t, "token", status.AuthMethod)
|
||||
assert.False(t, status.Helper)
|
||||
})
|
||||
|
||||
t.Run("invalid token", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"message":"token is invalid"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
status := CheckLoginStatus(context.Background(), &config.Login{
|
||||
Name: "test",
|
||||
URL: server.URL,
|
||||
Token: "expired-token",
|
||||
VersionCheck: false,
|
||||
})
|
||||
|
||||
assert.False(t, status.Valid)
|
||||
assert.Contains(t, status.Error, "token is invalid")
|
||||
})
|
||||
|
||||
t.Run("missing token", func(t *testing.T) {
|
||||
status := CheckLoginStatus(context.Background(), &config.Login{
|
||||
Name: "test",
|
||||
URL: "https://gitea.example.com",
|
||||
})
|
||||
|
||||
assert.False(t, status.Valid)
|
||||
assert.Contains(t, status.Error, "no access token configured")
|
||||
})
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/print"
|
||||
)
|
||||
|
||||
// CreateMilestone creates a milestone in the given repo and prints the result
|
||||
func CreateMilestone(ctx stdctx.Context, login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error {
|
||||
// title is required
|
||||
if len(title) == 0 {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
|
||||
mile, _, err := login.Client().Repositories.CreateMilestone(ctx, repoOwner, repoName, gitea.CreateMilestoneOption{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Deadline: deadline,
|
||||
State: state,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.MilestoneDetails(mile)
|
||||
return nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
local_git "gitea.dev/tea/modules/git"
|
||||
)
|
||||
|
||||
// PullCheckout checkout current workdir to the head branch of specified pull request
|
||||
func PullCheckout(
|
||||
ctx stdctx.Context,
|
||||
login *config.Login,
|
||||
repoOwner, repoName string,
|
||||
forceCreateBranch bool,
|
||||
index int64,
|
||||
callback func(string) (string, error),
|
||||
) error {
|
||||
client := login.Client()
|
||||
pr, _, err := client.PullRequests.GetPullRequest(ctx, repoOwner, repoName, index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't fetch PR: %s", err)
|
||||
}
|
||||
|
||||
// FIXME: should use ctx.LocalRepo..?
|
||||
localRepo, err := local_git.RepoForWorkdir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// find or create a matching remote
|
||||
remoteURL := remoteURLForPR(login, pr)
|
||||
newRemoteName := fmt.Sprintf("pulls/%v", pr.Head.Repository.Owner.UserName)
|
||||
// verify related remote is in local repo, otherwise add it
|
||||
localRemote, err := localRepo.GetOrCreateRemote(remoteURL, newRemoteName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localRemoteName := localRemote.Config().Name
|
||||
|
||||
localRemoteBranchName, err := doPRFetch(login, pr, localRepo, localRemote, callback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return doPRCheckout(localRepo, pr, localRemoteName, localRemoteBranchName, remoteURL, forceCreateBranch)
|
||||
}
|
||||
|
||||
func isRemoteDeleted(pr *gitea.PullRequest) bool {
|
||||
return pr.Head.Ref == fmt.Sprintf("refs/pull/%d/head", pr.Index)
|
||||
}
|
||||
|
||||
func remoteURLForPR(login *config.Login, pr *gitea.PullRequest) string {
|
||||
repo := pr.Head.Repository
|
||||
if isRemoteDeleted(pr) {
|
||||
repo = pr.Base.Repository
|
||||
}
|
||||
if login.SSHKeyPath() != "" || login.SSHAgent {
|
||||
// Use SSH when a key file is configured, or when the login authenticates
|
||||
// through a running ssh-agent. In both cases we have a working SSH setup.
|
||||
return repo.SSHURL
|
||||
}
|
||||
return repo.CloneURL
|
||||
}
|
||||
|
||||
func doPRFetch(
|
||||
login *config.Login,
|
||||
pr *gitea.PullRequest,
|
||||
localRepo *local_git.TeaRepo,
|
||||
localRemote *local_git.Remote,
|
||||
callback func(string) (string, error),
|
||||
) (string, error) {
|
||||
_ = callback
|
||||
localRemoteName := localRemote.Config().Name
|
||||
localBranchName := pr.Head.Ref
|
||||
url, err := localRepo.TeaRemoteURL(localRemoteName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
auth, err := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKeyPath(), callback)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
refspecs := []string{}
|
||||
if isRemoteDeleted(pr) {
|
||||
// When the head branch is already deleted, pr.Head.Ref points to
|
||||
// `refs/pull/<idx>/head`, where the commits stay available.
|
||||
// This ref must be fetched explicitly, and does not allow pushing, so we use it
|
||||
// only in this case as fallback.
|
||||
localBranchName = fmt.Sprintf("pulls/%d", pr.Index)
|
||||
refspecs = []string{fmt.Sprintf("%s:refs/remotes/%s/%s",
|
||||
pr.Head.Ref,
|
||||
localRemoteName,
|
||||
localBranchName,
|
||||
)}
|
||||
}
|
||||
fmt.Printf("Fetching PR %v (head %s:%s) from remote '%s'\n", pr.Index, url, pr.Head.Ref, localRemoteName)
|
||||
|
||||
err = localRepo.Fetch(localRemoteName, refspecs, auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return localBranchName, nil
|
||||
}
|
||||
|
||||
func doPRCheckout(
|
||||
localRepo *local_git.TeaRepo,
|
||||
pr *gitea.PullRequest,
|
||||
localRemoteName,
|
||||
localRemoteBranchName,
|
||||
remoteURL string,
|
||||
forceCreateBranch bool,
|
||||
) error {
|
||||
// determine the ref to checkout, depending on existence of a matching commit on a local branch
|
||||
var info string
|
||||
var checkoutRef local_git.ReferenceName
|
||||
|
||||
if b, _ := localRepo.TeaFindBranchBySha(pr.Head.Sha, remoteURL); b != nil {
|
||||
|
||||
// if a matching branch exists, use that
|
||||
checkoutRef = local_git.NewBranchReferenceName(b.Name)
|
||||
info = fmt.Sprintf("Found matching local branch %s, checking it out", checkoutRef.Short())
|
||||
|
||||
} else if forceCreateBranch {
|
||||
|
||||
// create a branch if wanted
|
||||
localBranchName := fmt.Sprintf("pulls/%v", pr.Index)
|
||||
if isRemoteDeleted(pr) {
|
||||
localBranchName += "-" + pr.Head.Ref
|
||||
}
|
||||
checkoutRef = local_git.NewBranchReferenceName(localBranchName)
|
||||
if err := localRepo.TeaCreateBranch(localBranchName, localRemoteBranchName, localRemoteName); err == nil {
|
||||
info = fmt.Sprintf("Created branch '%s'\n", localBranchName)
|
||||
} else if err == local_git.ErrBranchExists {
|
||||
info = "There may be changes since you last checked out, run `git pull` to get them."
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// use the remote tracking branch
|
||||
checkoutRef = local_git.NewRemoteReferenceName(localRemoteName, localRemoteBranchName)
|
||||
info = fmt.Sprintf(
|
||||
"Checking out remote tracking branch %s. To make changes, create a new branch:\n git checkout %s",
|
||||
checkoutRef.String(), localRemoteBranchName)
|
||||
|
||||
}
|
||||
|
||||
fmt.Println(info)
|
||||
return localRepo.TeaCheckout(checkoutRef)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
local_git "gitea.dev/tea/modules/git"
|
||||
)
|
||||
|
||||
// PullClean deletes local & remote feature-branches for a closed pull
|
||||
func PullClean(ctx stdctx.Context, login *config.Login, repoOwner, repoName string, index int64, ignoreSHA bool, callback func(string) (string, error)) error {
|
||||
client := login.Client()
|
||||
|
||||
repo, _, err := client.Repositories.GetRepo(ctx, repoOwner, repoName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultBranch := repo.DefaultBranch
|
||||
if len(defaultBranch) == 0 {
|
||||
defaultBranch = "master"
|
||||
}
|
||||
|
||||
// fetch PR source-repo & -branch from gitea
|
||||
pr, _, err := client.PullRequests.GetPullRequest(ctx, repoOwner, repoName, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pr.State == gitea.StateOpen {
|
||||
return fmt.Errorf("PR is still open, won't delete branches")
|
||||
}
|
||||
|
||||
// if remote head branch is already deleted, pr.Head.Ref points to "pulls/<idx>/head"
|
||||
remoteBranch := pr.Head.Ref
|
||||
remoteDeleted := isRemoteDeleted(pr)
|
||||
if remoteDeleted {
|
||||
remoteBranch = pr.Head.Name // this still holds the original branch name
|
||||
fmt.Printf("Remote branch '%s' already deleted.\n", remoteBranch)
|
||||
}
|
||||
|
||||
r, err := local_git.RepoForWorkdir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// find a branch with matching sha or name, that has a remote matching the repo url
|
||||
var branch *local_git.Branch
|
||||
if ignoreSHA {
|
||||
branch, err = r.TeaFindBranchByName(remoteBranch, pr.Head.Repository.CloneURL)
|
||||
} else {
|
||||
branch, err = r.TeaFindBranchBySha(pr.Head.Sha, pr.Head.Repository.CloneURL)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch == nil {
|
||||
if ignoreSHA {
|
||||
return fmt.Errorf("remote branch %s not found in local repo", remoteBranch)
|
||||
}
|
||||
return fmt.Errorf(`remote branch %s not found in local repo.
|
||||
Either you don't track this PR, or the local branch has diverged from the remote.
|
||||
If you still want to continue & are sure you don't loose any important commits,
|
||||
call me again with the --ignore-sha flag`, remoteBranch)
|
||||
}
|
||||
|
||||
// prepare deletion of local branch:
|
||||
headRef, err := r.Head()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headRef.Name().Short() == branch.Name {
|
||||
fmt.Printf("Checking out '%s' to delete local branch '%s'\n", defaultBranch, branch.Name)
|
||||
ref := local_git.NewBranchReferenceName(defaultBranch)
|
||||
if err = r.TeaCheckout(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// remove local & remote branch
|
||||
fmt.Printf("Deleting local branch %s\n", branch.Name)
|
||||
err = r.TeaDeleteLocalBranch(branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !remoteDeleted && pr.Head.Repository.Permissions.Push {
|
||||
fmt.Printf("Deleting remote branch %s\n", remoteBranch)
|
||||
url, urlErr := r.TeaRemoteURL(branch.Remote)
|
||||
if urlErr != nil {
|
||||
return urlErr
|
||||
}
|
||||
auth, authErr := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKeyPath(), callback)
|
||||
if authErr != nil {
|
||||
return authErr
|
||||
}
|
||||
return r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"gitea.dev/tea/modules/context"
|
||||
local_git "gitea.dev/tea/modules/git"
|
||||
"gitea.dev/tea/modules/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
spaceRegex = regexp.MustCompile(`[\s_-]+`)
|
||||
noSpace = regexp.MustCompile(`^[^a-zA-Z\s]*`)
|
||||
consecutive = regexp.MustCompile(`[\s]{2,}`)
|
||||
)
|
||||
|
||||
// CreatePull creates a PR in the given repo and returns the created PR
|
||||
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (*gitea.PullRequest, error) {
|
||||
var err error
|
||||
|
||||
// default is default branch
|
||||
if len(base) == 0 {
|
||||
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// default is current one
|
||||
if len(head) == 0 {
|
||||
if ctx.LocalRepo == nil {
|
||||
return nil, fmt.Errorf("no local git repo detected, please specify head branch")
|
||||
}
|
||||
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
|
||||
}
|
||||
|
||||
// head & base may not be the same
|
||||
if head == base {
|
||||
return nil, fmt.Errorf("can't create PR from %s to %s", head, base)
|
||||
}
|
||||
|
||||
// default is head branch name
|
||||
if len(opts.Title) == 0 {
|
||||
opts.Title = GetDefaultPRTitle(head)
|
||||
}
|
||||
// title is required
|
||||
if len(opts.Title) == 0 {
|
||||
return nil, fmt.Errorf("title is required")
|
||||
}
|
||||
|
||||
client := ctx.Login.Client()
|
||||
|
||||
pr, _, err := client.PullRequests.CreatePullRequest(requestCtx, ctx.Owner, ctx.Repo, gitea.CreatePullRequestOption{
|
||||
Head: head,
|
||||
Base: base,
|
||||
Title: opts.Title,
|
||||
Body: opts.Body,
|
||||
Assignees: opts.Assignees,
|
||||
Labels: opts.Labels,
|
||||
Milestone: opts.Milestone,
|
||||
Deadline: opts.Deadline,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err)
|
||||
}
|
||||
|
||||
if allowMaintainerEdits != nil && pr.AllowMaintainerEdit != *allowMaintainerEdits {
|
||||
pr, _, err = client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, pr.Index, gitea.EditPullRequestOption{
|
||||
AllowMaintainerEdit: allowMaintainerEdits,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not enable maintainer edit on pull: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// GetDefaultPRBase retrieves the default base branch for the given repo
|
||||
func GetDefaultPRBase(requestCtx stdctx.Context, login *config.Login, owner, repo string) (string, error) {
|
||||
meta, _, err := login.Client().Repositories.GetRepo(requestCtx, owner, repo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not fetch repo meta: %s", err)
|
||||
}
|
||||
return meta.DefaultBranch, nil
|
||||
}
|
||||
|
||||
// GetDefaultPRHead uses the currently checked out branch, tries to find a remote
|
||||
// that has a branch with the same name, and extracts the owner from its URL.
|
||||
// If no remote matches, owner is empty, meaning same as head repo owner.
|
||||
func GetDefaultPRHead(localRepo *local_git.TeaRepo) (owner, branch string, err error) {
|
||||
var sha string
|
||||
if branch, sha, err = localRepo.TeaGetCurrentBranchNameAndSHA(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
remote, err := localRepo.TeaFindBranchRemote(branch, sha)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("could not determine remote for current branch: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
if remote == nil {
|
||||
// if no remote branch is found for the local branch,
|
||||
// we leave owner empty, meaning "use same repo as head" to gitea.
|
||||
return
|
||||
}
|
||||
|
||||
url, err := local_git.ParseURL(remote.Config().URLs[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
owner, _ = utils.GetOwnerAndRepo(url.Path, "")
|
||||
return
|
||||
}
|
||||
|
||||
// GetHeadSpec creates a head string as expected by gitea API
|
||||
func GetHeadSpec(owner, branch, baseOwner string) string {
|
||||
if len(owner) != 0 && owner != baseOwner {
|
||||
return fmt.Sprintf("%s:%s", owner, branch)
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
// GetDefaultPRTitle transforms a string like a branchname to a readable text
|
||||
func GetDefaultPRTitle(header string) string {
|
||||
// Extract the part after the last colon in the input string
|
||||
colonIndex := strings.LastIndex(header, ":")
|
||||
if colonIndex != -1 {
|
||||
header = header[colonIndex+1:]
|
||||
}
|
||||
|
||||
title := noSpace.ReplaceAllString(header, "")
|
||||
title = spaceRegex.ReplaceAllString(title, " ")
|
||||
title = strings.TrimSpace(title)
|
||||
title = strings.Title(strings.ToLower(title))
|
||||
title = consecutive.ReplaceAllString(title, " ")
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
// CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result
|
||||
func CreateAgitFlowPull(requestCtx stdctx.Context, ctx *context.TeaContext, remote, head, base, topic string,
|
||||
opts *gitea.CreateIssueOption,
|
||||
callback func(string) (string, error),
|
||||
) (err error) {
|
||||
// default is default branch
|
||||
if len(base) == 0 {
|
||||
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// default is current one
|
||||
if len(head) == 0 {
|
||||
if ctx.LocalRepo == nil {
|
||||
return fmt.Errorf("no local git repo detected, please specify topic branch")
|
||||
}
|
||||
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
|
||||
}
|
||||
|
||||
if len(remote) == 0 {
|
||||
return fmt.Errorf("remote is required for agit flow PR")
|
||||
}
|
||||
|
||||
if len(topic) == 0 {
|
||||
topic = head
|
||||
}
|
||||
|
||||
if head == base || topic == base {
|
||||
return fmt.Errorf("can't create PR from %s to %s", topic, base)
|
||||
}
|
||||
|
||||
// default is head branch name
|
||||
if len(opts.Title) == 0 {
|
||||
opts.Title = GetDefaultPRTitle(head)
|
||||
}
|
||||
// title is required
|
||||
if len(opts.Title) == 0 {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
|
||||
localRepo, err := local_git.RepoForWorkdir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url, err := localRepo.RemoteURL(remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
auth, err := local_git.GetAuthForURL(url, ctx.Login.GetAccessToken(), ctx.Login.SSHKeyPath(), callback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetDefaultPRTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "Add new feature", want: "Add New Feature"},
|
||||
{input: "update-docs: Fix typo", want: "Fix Typo"},
|
||||
{input: "remove_long-string", want: "Remove Long String"},
|
||||
{input: "Replace_Underscores_With_Spaces", want: "Replace Underscores With Spaces"},
|
||||
{input: " leading-and-trailing-spaces ", want: "Leading And Trailing Spaces"},
|
||||
{input: "-----No--Upper--Case-----", want: "No Upper Case"},
|
||||
{input: "", want: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got := GetDefaultPRTitle(test.input)
|
||||
if got != test.want {
|
||||
t.Errorf("GetDefaultPRTitle(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/modules/context"
|
||||
)
|
||||
|
||||
// EditPull edits a pull request and returns the updated pull request.
|
||||
func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.PullRequest, error) {
|
||||
if client == nil {
|
||||
client = ctx.Login.Client()
|
||||
}
|
||||
|
||||
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, opts.AddLabels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, opts.RemoveLabels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addAssigneeOpts := ResolveAssigneeOpts(opts.AddAssignees)
|
||||
rmAssigneeOpts := ResolveAssigneeOpts(opts.RemoveAssignees)
|
||||
|
||||
prOpts := gitea.EditPullRequestOption{}
|
||||
var prOptsDirty bool
|
||||
if opts.Title != nil {
|
||||
prOpts.Title = *opts.Title
|
||||
prOptsDirty = true
|
||||
}
|
||||
if opts.Body != nil {
|
||||
prOpts.Body = opts.Body
|
||||
prOptsDirty = true
|
||||
}
|
||||
if opts.Milestone != nil {
|
||||
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *opts.Milestone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prOpts.Milestone = id
|
||||
prOptsDirty = true
|
||||
}
|
||||
if opts.Deadline != nil {
|
||||
prOpts.Deadline = opts.Deadline
|
||||
prOptsDirty = true
|
||||
if opts.Deadline.IsZero() {
|
||||
prOpts.RemoveDeadline = gitea.OptionalBool(true)
|
||||
}
|
||||
}
|
||||
opts.SetAssignees = cleanAssignees(opts.SetAssignees)
|
||||
if len(opts.SetAssignees) != 0 {
|
||||
prOpts.Assignees = opts.SetAssignees
|
||||
prOptsDirty = true
|
||||
}
|
||||
|
||||
if err := ApplyLabelChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pr *gitea.PullRequest
|
||||
if prOptsDirty {
|
||||
pr, _, err = client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, opts.Index, prOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not edit pull request: %s", err)
|
||||
}
|
||||
} else {
|
||||
pr, _, err = client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, opts.Index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get pull request: %s", err)
|
||||
}
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user