Add Forgejo namespace workflow stack
Some checks are pending
Build Rust / Cargo Test (push) Waiting to run
Build Site / Next.js Build (push) Waiting to run

This commit is contained in:
Conrad Kramer 2026-03-18 02:49:55 -07:00
parent 482fd5d085
commit 865b676c99
68 changed files with 9709 additions and 11 deletions

View file

@ -0,0 +1,454 @@
package forgejo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
)
type ScopeLevel string
const (
ScopeInstance ScopeLevel = "instance"
ScopeOrganization ScopeLevel = "organization"
ScopeRepository ScopeLevel = "repository"
)
type Scope struct {
Level ScopeLevel
Owner string
Name string
}
type Client struct {
baseURL *url.URL
token string
client *http.Client
}
type Runner struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Busy bool `json:"busy"`
Labels []RunnerLabel `json:"labels"`
}
type RunnerLabel struct {
Name string `json:"name"`
}
type RunJob struct {
ID int64 `json:"id"`
Name string `json:"name"`
RunsOn []string `json:"runs_on"`
Status string `json:"status"`
TaskID int64 `json:"task_id"`
}
type WebhookConfig struct {
URL string
ContentType string
Events []string
Active bool
}
type Option func(*Client)
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
if httpClient != nil {
c.client = httpClient
}
}
}
func NewClient(rawURL, token string, opts ...Option) (*Client, error) {
if rawURL == "" {
return nil, errors.New("forgejo base URL is required")
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
client := &Client{
baseURL: u,
token: strings.TrimSpace(token),
client: &http.Client{
Timeout: 30 * time.Second,
},
}
for _, opt := range opts {
opt(client)
}
if client.token == "" {
return nil, errors.New("forgejo token is required")
}
return client, nil
}
type registrationTokenResponse struct {
Token string `json:"token"`
TTL time.Time `json:"expires_at"`
}
func (c *Client) RegistrationToken(ctx context.Context, scope Scope) (string, error) {
endpoint, err := c.registrationEndpoint(scope)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("forgejo returned %s", resp.Status)
}
var decoded registrationTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return "", err
}
if decoded.Token == "" {
return "", errors.New("forgejo response missing token")
}
return decoded.Token, nil
}
func (c *Client) ListRunners(ctx context.Context, scope Scope) ([]Runner, error) {
endpoint, err := c.runnersEndpoint(scope)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("forgejo returned %s", resp.Status)
}
var decoded []Runner
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return nil, err
}
return decoded, nil
}
func (c *Client) ListRunJobs(ctx context.Context, scope Scope, labels []string) ([]RunJob, error) {
endpoint, err := c.runJobsEndpoint(scope)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
if len(labels) > 0 {
query := req.URL.Query()
query.Set("labels", strings.Join(labels, ","))
req.URL.RawQuery = query.Encode()
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("forgejo returned %s", resp.Status)
}
var decoded []RunJob
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return nil, err
}
if decoded == nil {
decoded = []RunJob{}
}
return decoded, nil
}
func (c *Client) EnsureWebhook(ctx context.Context, scope Scope, cfg WebhookConfig, secret string) error {
if cfg.URL == "" {
return nil
}
hooks, err := c.listWebhooks(ctx, scope)
if err != nil {
return err
}
for _, hook := range hooks {
if strings.EqualFold(hook.Config.URL, cfg.URL) {
return c.updateWebhook(ctx, scope, hook.ID, cfg, secret)
}
}
return c.createWebhook(ctx, scope, cfg, secret)
}
func (c *Client) registrationEndpoint(scope Scope) (string, error) {
var segments []string
switch scope.Level {
case ScopeRepository:
if scope.Owner == "" || scope.Name == "" {
return "", errors.New("repository scope requires owner and name")
}
segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "actions", "runners", "registration-token"}
case ScopeOrganization:
if scope.Owner == "" {
return "", errors.New("organization scope requires owner")
}
segments = []string{"api", "v1", "orgs", scope.Owner, "actions", "runners", "registration-token"}
case ScopeInstance:
segments = []string{"api", "v1", "admin", "actions", "runners", "registration-token"}
default:
return "", fmt.Errorf("unsupported scope level %q", scope.Level)
}
clone := *c.baseURL
clone.Path = path.Join(append([]string{clone.Path}, segments...)...)
return clone.String(), nil
}
type webhook struct {
ID int64 `json:"id"`
Config webhookConfigPayload `json:"config"`
}
type webhookConfigPayload struct {
URL string `json:"url"`
ContentType string `json:"content_type"`
}
func (c *Client) listWebhooks(ctx context.Context, scope Scope) ([]webhook, error) {
endpoint, err := c.webhooksEndpoint(scope)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("forgejo returned %s", resp.Status)
}
var hooks []webhook
if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil {
return nil, err
}
return hooks, nil
}
func (c *Client) createWebhook(ctx context.Context, scope Scope, cfg WebhookConfig, secret string) error {
payload := webhookRequestPayload{
Type: "gitea",
Config: map[string]string{
"url": cfg.URL,
"content_type": cfg.ContentType,
"secret": secret,
"insecure_ssl": "0",
},
Events: cfg.Events,
Active: cfg.Active,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint, err := c.webhooksEndpoint(scope)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("forgejo returned %s", resp.Status)
}
return nil
}
func (c *Client) updateWebhook(ctx context.Context, scope Scope, id int64, cfg WebhookConfig, secret string) error {
payload := webhookRequestPayload{
Type: "gitea",
Config: map[string]string{
"url": cfg.URL,
"content_type": cfg.ContentType,
"secret": secret,
"insecure_ssl": "0",
},
Events: cfg.Events,
Active: cfg.Active,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
endpoint, err := c.webhooksEndpoint(scope)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("%s/%d", endpoint, id), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("forgejo returned %s", resp.Status)
}
return nil
}
func (c *Client) webhooksEndpoint(scope Scope) (string, error) {
var segments []string
switch scope.Level {
case ScopeRepository:
if scope.Owner == "" || scope.Name == "" {
return "", errors.New("repository scope requires owner and name")
}
segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "hooks"}
case ScopeOrganization:
if scope.Owner == "" {
return "", errors.New("organization scope requires owner")
}
segments = []string{"api", "v1", "orgs", scope.Owner, "hooks"}
default:
return "", fmt.Errorf("webhook management not supported for scope level %q", scope.Level)
}
clone := *c.baseURL
clone.Path = path.Join(append([]string{clone.Path}, segments...)...)
return clone.String(), nil
}
type webhookRequestPayload struct {
Type string `json:"type"`
Config map[string]string `json:"config"`
Events []string `json:"events"`
Active bool `json:"active"`
}
func (c *Client) runnersEndpoint(scope Scope) (string, error) {
var segments []string
switch scope.Level {
case ScopeRepository:
if scope.Owner == "" || scope.Name == "" {
return "", errors.New("repository scope requires owner and name")
}
segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "actions", "runners"}
case ScopeOrganization:
if scope.Owner == "" {
return "", errors.New("organization scope requires owner")
}
segments = []string{"api", "v1", "orgs", scope.Owner, "actions", "runners"}
case ScopeInstance:
segments = []string{"api", "v1", "actions", "runners"}
default:
return "", fmt.Errorf("unsupported scope level %q", scope.Level)
}
clone := *c.baseURL
clone.Path = path.Join(append([]string{clone.Path}, segments...)...)
return clone.String(), nil
}
func (c *Client) runJobsEndpoint(scope Scope) (string, error) {
var segments []string
switch scope.Level {
case ScopeRepository:
if scope.Owner == "" || scope.Name == "" {
return "", errors.New("repository scope requires owner and name")
}
segments = []string{"api", "v1", "repos", scope.Owner, scope.Name, "actions", "runners", "jobs"}
case ScopeOrganization:
if scope.Owner == "" {
return "", errors.New("organization scope requires owner")
}
segments = []string{"api", "v1", "orgs", scope.Owner, "actions", "runners", "jobs"}
default:
return "", fmt.Errorf("run jobs not supported for scope level %q", scope.Level)
}
clone := *c.baseURL
clone.Path = path.Join(append([]string{clone.Path}, segments...)...)
return clone.String(), nil
}