dev-pod-api-build/internal/forgejo/client.go
2026-04-16 04:16:36 +00:00

264 lines
7.2 KiB
Go

package forgejo
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// Client talks to the Forgejo admin API for user management.
type Client struct {
baseURL string
adminUser string
adminPassword string
httpClient *http.Client
}
// NewClient creates a Forgejo API client.
func NewClient(baseURL, adminUser, adminPassword string) *Client {
return &Client{
baseURL: baseURL,
adminUser: adminUser,
adminPassword: adminPassword,
httpClient: &http.Client{},
}
}
// CreateForgejoUser creates a user in Forgejo and returns a personal access token.
func (c *Client) CreateForgejoUser(ctx context.Context, username string) (string, error) {
password, err := generatePassword(16)
if err != nil {
return "", fmt.Errorf("generate password: %w", err)
}
email := username + "@spinoff.dev"
if err := c.createUser(ctx, username, email, password); err != nil {
return "", fmt.Errorf("create forgejo user: %w", err)
}
token, err := c.createAccessToken(ctx, username, password, "dev-pod-access")
if err != nil {
_ = c.DeleteForgejoUser(ctx, username)
return "", fmt.Errorf("create forgejo token: %w", err)
}
return token, nil
}
// DeleteForgejoUser removes a user from Forgejo including all their repos.
func (c *Client) DeleteForgejoUser(ctx context.Context, username string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
fmt.Sprintf("%s/api/v1/admin/users/%s?purge=true", c.baseURL, username), nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.SetBasicAuth(c.adminUser, c.adminPassword)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete user request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete user: status %d: %s", resp.StatusCode, body)
}
return nil
}
func (c *Client) createUser(ctx context.Context, username, email, password string) error {
payload := map[string]interface{}{
"username": username,
"email": email,
"password": password,
"must_change_password": false,
"visibility": "private",
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/v1/admin/users", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.adminUser, c.adminPassword)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnprocessableEntity {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("user already exists or invalid: %s", respBody)
}
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
}
return nil
}
func (c *Client) createAccessToken(ctx context.Context, username, password, tokenName string) (string, error) {
payload := map[string]interface{}{
"name": tokenName,
"scopes": []string{
"write:repository",
"write:issue",
"write:organization",
"write:user",
"write:notification",
"write:misc",
"write:activitypub",
"write:package",
"read:repository",
"read:issue",
"read:organization",
"read:user",
"read:notification",
"read:misc",
"read:activitypub",
"read:package",
},
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/api/v1/users/%s/tokens", c.baseURL, username),
bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(username, password)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
}
var result struct {
SHA1 string `json:"sha1"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if result.SHA1 == "" {
return "", fmt.Errorf("empty token in response")
}
return result.SHA1, nil
}
// GetRepoFileContent reads a file from a Forgejo repository.
// Returns the raw file content. Returns nil with no error if the file does not exist.
func (c *Client) GetRepoFileContent(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath)
if ref != "" {
url += "?ref=" + ref
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.SetBasicAuth(c.adminUser, c.adminPassword)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
}
var result struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if result.Encoding != "base64" {
return []byte(result.Content), nil
}
decoded, err := base64.StdEncoding.DecodeString(result.Content)
if err != nil {
// Forgejo sometimes uses standard base64 with newlines
cleaned := strings.ReplaceAll(result.Content, "\n", "")
decoded, err = base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return nil, fmt.Errorf("decode base64 content: %w", err)
}
}
return decoded, nil
}
// CreateIssueComment posts a comment on a Forgejo issue.
func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, issueNumber int, body string) error {
payload := map[string]string{"body": body}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.adminUser, c.adminPassword)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
}
return nil
}
func generatePassword(length int) (string, error) {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}