Files
mesh-catalog/modules/gitea/cmd/forge/forge.go
T
jochen 9eb2ea8d69
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
gitea: let an agent put a commit on a branch of its own, and read history, compares, reviews, directories and code (issue 375)
An agent could not get a finished change onto the forge without someone
pushing by hand, so the branch / pull request / approval rule could not be
followed. A Go bundle serves gitea_commit, held to gitea_api's rule (never the
default branch, never a protected one), and the summarised reads agents
otherwise made through gitea_api as raw JSON.
2026-10-12 12:06:23 +02:00

260 lines
7.6 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account,
// which the vault delivered into a file this module owns (novox/hq ADR 0086), by basic authentication — the
// same way the npm-registry bundle beside it does. The password is read when a call is made and never logged,
// answered or put in an error.
// Forge is the forge's HTTP interface.
type Forge struct {
URL string // e.g. http://127.0.0.1:3000
User string
PasswordFile string
HTTP *http.Client
}
// ForgeFromEnv is the forge as the manifest hands it to this bundle.
func ForgeFromEnv() (*Forge, error) {
f := &Forge{
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"),
}
var missing []string
for _, kv := range [][2]string{{"MESH_GITEA_URL", f.URL}, {"MESH_GITEA_ADMIN_USER", f.User},
{"MESH_GITEA_ADMIN_PASSWORD_FILE", f.PasswordFile}} {
if kv[1] == "" {
missing = append(missing, kv[0])
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest", strings.Join(missing, ", "))
}
return f, nil
}
// errNotFound is the forge answering 404.
var errNotFound = errors.New("not found")
// statusError is an answer the forge gave that is not a success, said without the request's credential.
type statusError struct {
method, path string
status int
said string
}
func (e *statusError) Error() string {
return fmt.Sprintf("the forge answered %d to %s %s: %s", e.status, e.method, e.path, e.said)
}
func (f *Forge) client() *http.Client {
if f.HTTP != nil {
return f.HTTP
}
return &http.Client{Timeout: 50 * time.Second}
}
func (f *Forge) password() (string, error) {
raw, err := os.ReadFile(f.PasswordFile)
if err != nil {
// The path is the manifest's, not a secret; the content never appears.
return "", fmt.Errorf("the forge's admin password cannot be read from its file: %w", err)
}
return strings.TrimSpace(string(raw)), nil
}
// maxBody is the largest JSON answer read from the forge; maxArchive the largest archive code search reads.
const (
maxBody = 32 << 20
maxArchive = 64 << 20
)
// do sends one request and answers the body, read up to limit; 404 is errNotFound. A body the limit cut is
// refused, never read as if it were whole.
func (f *Forge) do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) {
password, err := f.password()
if err != nil {
return nil, err
}
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, f.URL+path, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(f.User, password)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := f.client().Do(req)
if err != nil {
return nil, fmt.Errorf("the forge did not answer %s %s: %w", method, path, err)
}
defer res.Body.Close()
got, err := io.ReadAll(io.LimitReader(res.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(got)) > limit {
return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20)
}
switch {
case res.StatusCode == http.StatusNotFound:
return nil, fmt.Errorf("%s %s: %w", method, path, errNotFound)
case res.StatusCode >= 300:
said := strings.TrimSpace(string(got))
if len(said) > 300 {
said = said[:300] + "…"
}
return nil, &statusError{method, path, res.StatusCode, said}
}
return got, nil
}
func (f *Forge) getJSON(ctx context.Context, path string, into any) error {
return f.sendJSON(ctx, http.MethodGet, path, nil, into)
}
func (f *Forge) sendJSON(ctx context.Context, method, path string, payload, into any) error {
body, err := f.do(ctx, method, path, payload, maxBody)
if err != nil {
return err
}
if into == nil {
return nil
}
if err := json.Unmarshal(body, into); err != nil {
return fmt.Errorf("the forge's answer to %s %s is not readable: %w", method, path, err)
}
return nil
}
// Refused is a refusal of policy or of an argument, in the words the caller sees.
type Refused struct{ msg string }
func (r *Refused) Error() string { return r.msg }
func refuse(format string, a ...any) error { return &Refused{fmt.Sprintf(format, a...)} }
var nameShape = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`)
// repoPath is /api/v1/repos/{owner}/{repo} for names that cannot become another path.
func repoPath(owner, repo string) (string, error) {
for _, v := range [][2]string{{"owner", owner}, {"name", repo}} {
if !nameShape.MatchString(v[1]) || v[1] == "." || v[1] == ".." {
return "", refuse("%q is not a repository %s", v[1], v[0])
}
}
return "/api/v1/repos/" + owner + "/" + repo, nil
}
// segments encodes a value that is several path segments — a branch like fix/1-x, a file's path — refusing
// any segment that could make the request another path: empty, `.` or `..`, or holding `?`, `#`, `%`, `\`
// or a control character (the same rule as the TypeScript bundle's pathSegments, guard.ts).
func segments(what, value string) (string, error) {
parts := strings.Split(value, "/")
for i, p := range parts {
if p == "" || p == "." || p == ".." || strings.ContainsAny(p, "?#%\\") || strings.IndexFunc(p, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
return "", refuse("%q is not a %s this tool puts in a path. Nothing was sent", value, what)
}
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/"), nil
}
// Repo is what the tools read of a repository.
type Repo struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
Empty bool `json:"empty"`
}
// Protection is one branch protection rule, as far as covering a branch goes.
type Protection struct {
RuleName string `json:"rule_name"`
BranchName string `json:"branch_name"`
}
func (p Protection) name() string {
if p.RuleName != "" {
return p.RuleName
}
return p.BranchName
}
// ruleCovers is whether a protection rule's name — a branch, or a glob of them (`*`, `**`, `?`) — covers a
// branch: the TypeScript bundle's ruleCovers (guard.ts), which gitea_api's file writes are judged by.
func ruleCovers(rule, branch string) bool {
if rule == branch {
return true
}
if !strings.ContainsAny(rule, "*?[") {
return false
}
var re strings.Builder
re.WriteString("^")
for i := 0; i < len(rule); i++ {
c := rule[i]
switch {
case c == '*' && i+1 < len(rule) && rule[i+1] == '*':
re.WriteString(".*")
i++
case c == '*':
re.WriteString("[^/]*")
case c == '?':
re.WriteString("[^/]")
default:
re.WriteString(regexp.QuoteMeta(string(c)))
}
}
re.WriteString("$")
ok, err := regexp.MatchString(re.String(), branch)
return err == nil && ok
}
// Branch is one branch with the commit it points at.
type Branch struct {
Name string `json:"name"`
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
// branch is the named branch, nil when the forge has none of that name.
func (f *Forge) branch(ctx context.Context, repo, name string) (*Branch, error) {
seg, err := segments("branch", name)
if err != nil {
return nil, err
}
var b Branch
if err := f.getJSON(ctx, repo+"/branches/"+seg, &b); err != nil {
if errors.Is(err, errNotFound) {
return nil, nil
}
return nil, err
}
return &b, nil
}