d76c6178d2
Build tstudio CLI / build (push) Failing after 3s
Login flow now: 1. Browser OAuth2 → JWT access token 2. SSH key gen + git credentials 3. Auto-creates a PAT named cursor-<hostname> for Cursor/DeepSeek 4. Displays the PAT with Cursor setup instructions (shown once) Fixed: API client now sends Bearer prefix for OAuth2 JWT tokens (was sending "token" prefix which Gitea rejects for JWTs). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
96 lines
2.8 KiB
Go
96 lines
2.8 KiB
Go
// Copyright 2026 Tinqs Ltd. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// apiRequest makes an authenticated request to the Gitea API.
|
|
func apiRequest(method, url, token string, body io.Reader) (*http.Response, error) {
|
|
req, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", UserAgent)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
// OAuth2 JWT tokens start with "ey", PATs start with "gta_" or are short hex
|
|
if len(token) > 100 || strings.HasPrefix(token, "ey") {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
} else {
|
|
req.Header.Set("Authorization", "token "+token)
|
|
}
|
|
}
|
|
return httpClient.Do(req)
|
|
}
|
|
|
|
// apiGet is a shorthand for authenticated GET.
|
|
func apiGet(instance, path, token string) (*http.Response, error) {
|
|
url := strings.TrimRight(instance, "/") + "/api/v1" + path
|
|
return apiRequest("GET", url, token, nil)
|
|
}
|
|
|
|
// apiPost is a shorthand for authenticated POST with JSON body.
|
|
func apiPost(instance, path, token string, body io.Reader) (*http.Response, error) {
|
|
url := strings.TrimRight(instance, "/") + "/api/v1" + path
|
|
return apiRequest("POST", url, token, body)
|
|
}
|
|
|
|
// apiDelete is a shorthand for authenticated DELETE.
|
|
func apiDelete(instance, path, token string) (*http.Response, error) {
|
|
url := strings.TrimRight(instance, "/") + "/api/v1" + path
|
|
return apiRequest("DELETE", url, token, nil)
|
|
}
|
|
|
|
// decodeJSON reads the response body and decodes JSON into v.
|
|
func decodeJSON(resp *http.Response, v any) error {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(v)
|
|
}
|
|
|
|
// GiteaUser represents a Gitea user response.
|
|
type GiteaUser struct {
|
|
ID int64 `json:"id"`
|
|
Login string `json:"login"`
|
|
FullName string `json:"full_name"`
|
|
Email string `json:"email"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
}
|
|
|
|
// GiteaRepo represents a Gitea repository response.
|
|
type GiteaRepo struct {
|
|
ID int64 `json:"id"`
|
|
FullName string `json:"full_name"`
|
|
Description string `json:"description"`
|
|
Private bool `json:"private"`
|
|
Fork bool `json:"fork"`
|
|
HTMLURL string `json:"html_url"`
|
|
CloneURL string `json:"clone_url"`
|
|
Stars int `json:"stars_count"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// GiteaToken represents a Gitea access token.
|
|
type GiteaToken struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
SHA1 string `json:"sha1"`
|
|
}
|