Add api subcommand for arbitrary api calls not covered by existing subcommands (#879)

Reviewed-on: https://gitea.com/gitea/tea/pulls/879
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
This commit is contained in:
techknowlogick
2026-02-03 20:24:21 +00:00
committed by techknowlogick
parent 6414a5e00e
commit 82d8a14c73
9 changed files with 540 additions and 133 deletions

View File

@@ -7,7 +7,7 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH)
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
# Tool packages with pinned versions # Tool packages with pinned versions
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.7.0 GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
ifneq ($(DRONE_TAG),) ifneq ($(DRONE_TAG),)

274
cmd/api.go Normal file
View File

@@ -0,0 +1,274 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
// CmdApi represents the api command
var CmdApi = cli.Command{
Name: "api",
Usage: "Make an authenticated API request",
Description: `Makes an authenticated HTTP request to the Gitea API and prints the response.
The endpoint argument is the path to the API endpoint, which will be prefixed
with /api/v1/ if it doesn't start with /api/ or http(s)://.
Placeholders like {owner} and {repo} in the endpoint will be replaced with
values from the current repository context.
Use -f for string fields and -F for typed fields (numbers, booleans, null).
With -F, prefix value with @ to read from file (@- for stdin).`,
ArgsUsage: "<endpoint>",
Action: runApi,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "method",
Aliases: []string{"X"},
Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
Value: "GET",
},
&cli.StringSliceFlag{
Name: "field",
Aliases: []string{"f"},
Usage: "Add a string field to the request body (key=value)",
},
&cli.StringSliceFlag{
Name: "Field",
Aliases: []string{"F"},
Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)",
},
&cli.StringSliceFlag{
Name: "header",
Aliases: []string{"H"},
Usage: "Add a custom header (key:value)",
},
&cli.BoolFlag{
Name: "include",
Aliases: []string{"i"},
Usage: "Include HTTP status and response headers in output (written to stderr)",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Write response body to file instead of stdout (use '-' for stdout)",
},
}, flags.LoginRepoFlags...),
}
func runApi(_ stdctx.Context, cmd *cli.Command) error {
ctx := context.InitCommand(cmd)
// Get the endpoint argument
if cmd.NArg() < 1 {
return fmt.Errorf("endpoint argument required")
}
endpoint := cmd.Args().First()
// Expand placeholders in endpoint
endpoint = expandPlaceholders(endpoint, ctx)
// Parse headers
headers := make(map[string]string)
for _, h := range cmd.StringSlice("header") {
parts := strings.SplitN(h, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid header format: %q (expected key:value)", h)
}
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
// Build request body from fields
var body io.Reader
stringFields := cmd.StringSlice("field")
typedFields := cmd.StringSlice("Field")
if len(stringFields) > 0 || len(typedFields) > 0 {
bodyMap := make(map[string]any)
// Process string fields (-f)
for _, f := range stringFields {
parts := strings.SplitN(f, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid field format: %q (expected key=value)", f)
}
bodyMap[parts[0]] = parts[1]
}
// Process typed fields (-F)
for _, f := range typedFields {
parts := strings.SplitN(f, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid field format: %q (expected key=value)", f)
}
key := parts[0]
value := parts[1]
parsedValue, err := parseTypedValue(value)
if err != nil {
return fmt.Errorf("failed to parse field %q: %w", key, err)
}
bodyMap[key] = parsedValue
}
bodyBytes, err := json.Marshal(bodyMap)
if err != nil {
return fmt.Errorf("failed to encode request body: %w", err)
}
body = strings.NewReader(string(bodyBytes))
}
// Create API client and make request
client := api.NewClient(ctx.Login)
method := strings.ToUpper(cmd.String("method"))
resp, err := client.Do(method, endpoint, body, headers)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Print headers to stderr if requested (so redirects/pipes work correctly)
if cmd.Bool("include") {
fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status)
for key, values := range resp.Header {
for _, value := range values {
fmt.Fprintf(os.Stderr, "%s: %s\n", key, value)
}
}
fmt.Fprintln(os.Stderr)
}
// Determine output destination
outputPath := cmd.String("output")
forceStdout := outputPath == "-"
outputToStdout := outputPath == "" || forceStdout
// Check for binary output to terminal (skip warning if user explicitly forced stdout)
if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) {
fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o <file>' to save to a file,")
fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.")
return nil
}
var output io.Writer = os.Stdout
if !outputToStdout {
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
output = file
}
// Copy response body to output
_, err = io.Copy(output, resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
// Add newline for better terminal display
if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) {
fmt.Println()
}
return nil
}
// parseTypedValue parses a value for -F flag, handling:
// - @filename: read content from file
// - @-: read content from stdin
// - true/false: boolean
// - null: nil
// - numbers: int or float
// - otherwise: string
func parseTypedValue(value string) (any, error) {
// Handle file references
if strings.HasPrefix(value, "@") {
filename := value[1:]
var content []byte
var err error
if filename == "-" {
content, err = io.ReadAll(os.Stdin)
} else {
content, err = os.ReadFile(filename)
}
if err != nil {
return nil, fmt.Errorf("failed to read %q: %w", value, err)
}
return strings.TrimSuffix(string(content), "\n"), nil
}
// Handle null
if value == "null" {
return nil, nil
}
// Handle booleans
if value == "true" {
return true, nil
}
if value == "false" {
return false, nil
}
// Handle integers
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i, nil
}
// Handle floats
if f, err := strconv.ParseFloat(value, 64); err == nil {
return f, nil
}
// Default to string
return value, nil
}
// isTextContentType returns true if the content type indicates text data
func isTextContentType(contentType string) bool {
if contentType == "" {
return true // assume text if unknown
}
contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset
return strings.HasPrefix(contentType, "text/") ||
strings.Contains(contentType, "json") ||
strings.Contains(contentType, "xml") ||
strings.Contains(contentType, "javascript") ||
strings.Contains(contentType, "yaml") ||
strings.Contains(contentType, "toml")
}
// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint
func expandPlaceholders(endpoint string, ctx *context.TeaContext) string {
endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner)
endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo)
// Get current branch if available
if ctx.LocalRepo != nil {
if branch, err := ctx.LocalRepo.Head(); err == nil {
branchName := branch.Name().Short()
endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName)
}
}
return endpoint
}

View File

@@ -59,6 +59,7 @@ func App() *cli.Command {
&CmdAdmin, &CmdAdmin,
&CmdApi,
&CmdGenerateManPage, &CmdGenerateManPage,
}, },
EnableShellCompletion: true, EnableShellCompletion: true,

View File

@@ -11,9 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time"
"code.gitea.io/tea/modules/auth"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -59,6 +57,13 @@ var CmdLoginHelper = cli.Command{
{ {
Name: "get", Name: "get",
Description: "Get token to auth", Description: "Get token to auth",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "login",
Aliases: []string{"l"},
Usage: "Use a specific login",
},
},
Action: func(_ context.Context, cmd *cli.Command) error { Action: func(_ context.Context, cmd *cli.Command) error {
wants := map[string]string{} wants := map[string]string{}
s := bufio.NewScanner(os.Stdin) s := bufio.NewScanner(os.Stdin)
@@ -93,10 +98,21 @@ var CmdLoginHelper = cli.Command{
wants["protocol"] = "http" wants["protocol"] = "http"
} }
userConfig := config.GetLoginByHost(wants["host"]) // Use --login flag if provided, otherwise fall back to host lookup
var userConfig *config.Login
if loginName := cmd.String("login"); loginName != "" {
userConfig = config.GetLoginByName(loginName)
if userConfig == nil { if userConfig == nil {
log.Fatal("host not exists") log.Fatalf("Login '%s' not found", loginName)
} else if len(userConfig.Token) == 0 { }
} else {
userConfig = config.GetLoginByHost(wants["host"])
if userConfig == nil {
log.Fatalf("No login found for host '%s'", wants["host"])
}
}
if len(userConfig.Token) == 0 {
log.Fatal("User no set") log.Fatal("User no set")
} }
@@ -105,20 +121,11 @@ var CmdLoginHelper = cli.Command{
return err return err
} }
if userConfig.TokenExpiry > 0 && time.Now().Unix() > userConfig.TokenExpiry { // Refresh token if expired or near expiry (updates userConfig in place)
// Token is expired, refresh it if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil {
err = auth.RefreshAccessToken(userConfig)
if err != nil {
return err return err
} }
// Once token is refreshed, get the latest from the updated config
refreshedConfig := config.GetLoginByHost(wants["host"])
if refreshedConfig != nil {
userConfig = refreshedConfig
}
}
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token) _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token)
if err != nil { if err != nil {
return err return err

View File

@@ -1712,3 +1712,25 @@ List Users
**--remote, -R**="": Discover Gitea login from remote. Optional **--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## api
Make an authenticated API request
**--Field, -F**="": Add a typed field to the request body (key=value, @file, or @- for stdin)
**--field, -f**="": Add a string field to the request body (key=value)
**--header, -H**="": Add a custom header (key:value)
**--include, -i**: Include HTTP status and response headers in output (written to stderr)
**--login, -l**="": Use a different Gitea Login. Optional
**--method, -X**="": HTTP method (GET, POST, PUT, PATCH, DELETE) (default: "GET")
**--output, -o**="": Write response body to file instead of stdout (use '-' for stdout)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional

16
main.go
View File

@@ -16,7 +16,7 @@ import (
func main() { func main() {
app := cmd.App() app := cmd.App()
app.Flags = append(app.Flags, debug.CliFlag()) app.Flags = append(app.Flags, debug.CliFlag())
err := app.Run(context.Background(), os.Args) err := app.Run(context.Background(), preprocessArgs(os.Args))
if err != nil { if err != nil {
// app.Run already exits for errors implementing ErrorCoder, // app.Run already exits for errors implementing ErrorCoder,
// so we only handle generic errors with code 1 here. // so we only handle generic errors with code 1 here.
@@ -24,3 +24,17 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
// preprocessArgs normalizes command-line arguments.
// Converts "-o-" to "-o -" for the api command's output flag.
func preprocessArgs(args []string) []string {
result := make([]string, 0, len(args)+1)
for _, arg := range args {
if arg == "-o-" {
result = append(result, "-o", "-")
} else {
result = append(result, arg)
}
}
return result
}

105
modules/api/client.go Normal file
View File

@@ -0,0 +1,105 @@
// 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"
"code.gitea.io/tea/modules/config"
)
// 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: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure},
},
}
return &Client{
baseURL: strings.TrimSuffix(login.URL, "/"),
token: login.Token,
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
}

View File

@@ -27,9 +27,6 @@ import (
// Constants for OAuth2 PKCE flow // Constants for OAuth2 PKCE flow
const ( const (
// default client ID included in most Gitea instances
defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
// default scopes to request // default scopes to request
defaultScopes = "admin,user,issue,misc,notification,organization,package,repository" defaultScopes = "admin,user,issue,misc,notification,organization,package,repository"
@@ -65,7 +62,7 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error {
Name: name, Name: name,
URL: giteaURL, URL: giteaURL,
Insecure: insecure, Insecure: insecure,
ClientID: defaultClientID, ClientID: config.DefaultClientID,
RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort), RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort),
Port: redirectPort, Port: redirectPort,
} }
@@ -82,7 +79,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error {
// Set defaults if needed // Set defaults if needed
if opts.ClientID == "" { if opts.ClientID == "" {
opts.ClientID = defaultClientID opts.ClientID = config.DefaultClientID
} }
// If the redirect URL is specified, parse it to extract port if needed // If the redirect URL is specified, parse it to extract port if needed
@@ -414,55 +411,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure
return nil return nil
} }
// RefreshAccessToken manually renews an expired access token using the refresh token // RefreshAccessToken manually renews an access token using the refresh token.
// This is used by the "tea login oauth-refresh" command for explicit token refresh.
// For automatic threshold-based refresh, use login.Client() which handles it internally.
func RefreshAccessToken(login *config.Login) error { func RefreshAccessToken(login *config.Login) error {
if login.RefreshToken == "" { return login.RefreshOAuthToken()
return fmt.Errorf("no refresh token available")
}
// Check if token actually needs refreshing
if login.TokenExpiry > 0 && time.Now().Unix() < login.TokenExpiry {
// Token is still valid, no need to refresh
return nil
}
// Create an expired Token object
expiredToken := &oauth2.Token{
AccessToken: login.Token,
RefreshToken: login.RefreshToken,
// Set expiry in the past to force refresh
Expiry: time.Unix(login.TokenExpiry, 0),
}
// Set up the OAuth2 config
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(login.Insecure))
// Configure the OAuth2 endpoints
oauth2Config := &oauth2.Config{
ClientID: defaultClientID,
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", login.URL),
},
}
// Refresh the token
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token()
if err != nil {
return fmt.Errorf("failed to refresh token: %s", err)
}
// Update login with new token information
login.Token = newToken.AccessToken
if newToken.RefreshToken != "" {
login.RefreshToken = newToken.RefreshToken
}
if !newToken.Expiry.IsZero() {
login.TokenExpiry = newToken.Expiry.Unix()
}
// Save updated login to config
return config.UpdateLogin(login)
} }

View File

@@ -24,6 +24,13 @@ import (
"golang.org/x/oauth2" "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"
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server // Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct { type Login struct {
Name string `yaml:"name"` Name string `yaml:"name"`
@@ -129,21 +136,31 @@ func GetLoginByToken(token string) *Login {
// GetLoginByHost finds a login by it's server URL // GetLoginByHost finds a login by it's server URL
func GetLoginByHost(host string) *Login { func GetLoginByHost(host string) *Login {
logins := GetLoginsByHost(host)
if len(logins) > 0 {
return logins[0]
}
return nil
}
// GetLoginsByHost returns all logins matching a host
func GetLoginsByHost(host string) []*Login {
err := loadConfig() err := loadConfig()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
for _, l := range config.Logins { var matches []*Login
loginURL, err := url.Parse(l.URL) for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if loginURL.Host == host { if loginURL.Host == host {
return &l matches = append(matches, &config.Logins[i])
} }
} }
return nil return matches
} }
// DeleteLogin delete a login by name from config // DeleteLogin delete a login by name from config
@@ -208,48 +225,58 @@ func UpdateLogin(login *Login) error {
return saveConfig() return saveConfig()
} }
// Client returns a client to operate Gitea API. You may provide additional modifiers // RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
// for the client like gitea.SetBasicAuth() for customization // Returns nil without doing anything if no refresh is needed.
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { func (l *Login) RefreshOAuthTokenIfNeeded() error {
// Check if token needs refreshing (if we have a refresh token and expiry time) if l.RefreshToken == "" || l.TokenExpiry == 0 {
if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry { return nil
// Since we can't directly call auth.RefreshAccessToken due to import cycles, }
// we'll implement the token refresh logic here. expiryTime := time.Unix(l.TokenExpiry, 0)
// Create an expired Token object if time.Now().Add(TokenRefreshThreshold).After(expiryTime) {
expiredToken := &oauth2.Token{ 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.
func (l *Login) RefreshOAuthToken() error {
if l.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
// Create a Token object with current values
currentToken := &oauth2.Token{
AccessToken: l.Token, AccessToken: l.Token,
RefreshToken: l.RefreshToken, RefreshToken: l.RefreshToken,
// Set expiry in the past to force refresh
Expiry: time.Unix(l.TokenExpiry, 0), Expiry: time.Unix(l.TokenExpiry, 0),
} }
// Set up the OAuth2 config // Set up the OAuth2 config
ctx := context.Background() ctx := context.Background()
// Create HTTP client with proper insecure settings // Create HTTP client, respecting the login's TLS settings
httpClient := &http.Client{} httpClient := &http.Client{
if l.Insecure {
httpClient = &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
}, },
} }
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Configure the OAuth2 endpoints // Configure the OAuth2 endpoints
oauth2Config := &oauth2.Config{ oauth2Config := &oauth2.Config{
ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go ClientID: DefaultClientID,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
}, },
} }
// Refresh the token // Refresh the token
newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
if err != nil { if err != nil {
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) return fmt.Errorf("failed to refresh token: %w", err)
} }
// Update login with new token information // Update login with new token information
l.Token = newToken.AccessToken l.Token = newToken.AccessToken
@@ -262,9 +289,15 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
} }
// Save updated login to config // Save updated login to config
if err := UpdateLogin(l); err != nil { return UpdateLogin(l)
log.Fatalf("Failed to save refreshed token: %s\n", err) }
}
// 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 {
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
} }
httpClient := &http.Client{} httpClient := &http.Client{}