build source

This commit is contained in:
build 2026-04-16 04:16:36 +00:00
commit ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions

Binary file not shown.

Binary file not shown.

264
internal/forgejo/client.go Normal file
View file

@ -0,0 +1,264 @@
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
}

View file

@ -0,0 +1,269 @@
package forgejo
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateForgejoUser_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/admin/users", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["username"] != "alice" {
t.Fatalf("expected username 'alice', got %v", body["username"])
}
if body["must_change_password"] != false {
t.Fatal("expected must_change_password false")
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 2, "login": "alice"})
})
mux.HandleFunc("POST /api/v1/users/alice/tokens", func(w http.ResponseWriter, r *http.Request) {
user, _, ok := r.BasicAuth()
if !ok || user != "alice" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["name"] != "dev-pod-access" {
t.Fatalf("expected token name 'dev-pod-access', got %v", body["name"])
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{"sha1": "tok_abc123"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
token, err := client.CreateForgejoUser(context.Background(), "alice")
if err != nil {
t.Fatalf("CreateForgejoUser: %v", err)
}
if token != "tok_abc123" {
t.Fatalf("expected token 'tok_abc123', got %q", token)
}
}
func TestCreateForgejoUser_CreateUserFails(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/admin/users", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message": "user already exists"}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
_, err := client.CreateForgejoUser(context.Background(), "alice")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "user already exists") {
t.Fatalf("expected 'user already exists' in error, got: %v", err)
}
}
func TestCreateForgejoUser_TokenCreationFails_CleansUpUser(t *testing.T) {
var isUserDeleted bool
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/admin/users", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 2, "login": "alice"})
})
mux.HandleFunc("POST /api/v1/users/alice/tokens", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message": "db error"}`))
})
mux.HandleFunc("DELETE /api/v1/admin/users/alice", func(w http.ResponseWriter, _ *http.Request) {
isUserDeleted = true
w.WriteHeader(http.StatusNoContent)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
_, err := client.CreateForgejoUser(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on token creation failure")
}
if !isUserDeleted {
t.Fatal("expected user to be cleaned up after token creation failure")
}
}
func TestDeleteForgejoUser_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("DELETE /api/v1/admin/users/alice", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("purge") != "true" {
t.Fatal("expected purge=true query param")
}
w.WriteHeader(http.StatusNoContent)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
if err := client.DeleteForgejoUser(context.Background(), "alice"); err != nil {
t.Fatalf("DeleteForgejoUser: %v", err)
}
}
func TestDeleteForgejoUser_NotFound_NoError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("DELETE /api/v1/admin/users/alice", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
if err := client.DeleteForgejoUser(context.Background(), "alice"); err != nil {
t.Fatalf("expected no error for 404, got: %v", err)
}
}
func TestGetRepoFileContent_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/repos/alice/myrepo/contents/.spinoff.yml", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("ref") != "main" {
t.Fatalf("expected ref=main, got %s", r.URL.Query().Get("ref"))
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"content": "dG9vbHM6IFtydXN0XQo=",
"encoding": "base64",
})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
content, err := client.GetRepoFileContent(context.Background(), "alice", "myrepo", ".spinoff.yml", "main")
if err != nil {
t.Fatalf("GetRepoFileContent: %v", err)
}
if string(content) != "tools: [rust]\n" {
t.Fatalf("expected 'tools: [rust]\\n', got %q", string(content))
}
}
func TestGetRepoFileContent_NotFound(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/repos/alice/myrepo/contents/.spinoff.yml", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
content, err := client.GetRepoFileContent(context.Background(), "alice", "myrepo", ".spinoff.yml", "main")
if err != nil {
t.Fatalf("expected no error for 404, got: %v", err)
}
if content != nil {
t.Fatalf("expected nil content for 404, got %q", string(content))
}
}
func TestCreateIssueComment_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/repos/alice/myrepo/issues/42/comments", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["body"] != "Builder pod created" {
t.Fatalf("expected body 'Builder pod created', got %q", body["body"])
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
err := client.CreateIssueComment(context.Background(), "alice", "myrepo", 42, "Builder pod created")
if err != nil {
t.Fatalf("CreateIssueComment: %v", err)
}
}
func TestCreateIssueComment_Failure(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/repos/alice/myrepo/issues/42/comments", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message": "forbidden"}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "admin", "secret")
err := client.CreateIssueComment(context.Background(), "alice", "myrepo", 42, "test")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "403") {
t.Fatalf("expected 403 in error, got: %v", err)
}
}
func TestGeneratePassword(t *testing.T) {
p1, err := generatePassword(16)
if err != nil {
t.Fatalf("generatePassword: %v", err)
}
if len(p1) != 32 {
t.Fatalf("expected 32 hex chars, got %d", len(p1))
}
p2, _ := generatePassword(16)
if p1 == p2 {
t.Fatal("expected different passwords")
}
}