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
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.
333 lines
12 KiB
Go
333 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
|
|
// request / approval rule without anyone pushing by hand. By the rule gitea_api already enforces for a file
|
|
// written (novox/hq ADR 0266): never the repository's default branch, never a branch a protection rule names.
|
|
// Nothing about merging changes — that still needs a review at the head and both required checks green
|
|
// (gitea_merge_pull_request).
|
|
|
|
// FileChange is one file of a commit: its new content, as text or base64, or its deletion.
|
|
type FileChange struct {
|
|
Path string
|
|
Content *string
|
|
ContentBase64 *string
|
|
Delete bool
|
|
}
|
|
|
|
// CommitAsk is one gitea_commit call.
|
|
type CommitAsk struct {
|
|
Owner, Repo string
|
|
Branch string
|
|
Base string // the branch or commit a new branch starts from; the default branch when empty
|
|
Message string
|
|
Files []FileChange
|
|
Patch string // a unified diff, as git diff writes it, instead of Files
|
|
}
|
|
|
|
// Committed is what gitea_commit made.
|
|
type Committed struct {
|
|
Repository string `json:"repository"`
|
|
Branch string `json:"branch"`
|
|
Created bool `json:"created"`
|
|
Base string `json:"base,omitempty"`
|
|
Parent string `json:"parent"`
|
|
Commit string `json:"commit"`
|
|
Files []CommittedFile `json:"files,omitempty"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
// CommittedFile is one file the commit changed, and how.
|
|
type CommittedFile struct {
|
|
Path string `json:"path"`
|
|
Operation string `json:"operation"`
|
|
}
|
|
|
|
// maxFiles is the most files one commit carries.
|
|
const maxFiles = 200
|
|
|
|
// readCommitAsk reads the tool's arguments.
|
|
func readCommitAsk(args map[string]any) (CommitAsk, error) {
|
|
a := CommitAsk{Owner: text(args, "owner"), Repo: text(args, "repo"), Branch: text(args, "branch"),
|
|
Base: text(args, "base"), Message: strings.TrimSpace(raw(args, "message"))}
|
|
a.Patch = raw(args, "patch")
|
|
if a.Branch == "" {
|
|
return a, refuse("branch is required: the branch the commit goes on, a branch of its own (created from base when absent). Nothing was sent")
|
|
}
|
|
if a.Message == "" {
|
|
return a, refuse("message is required: the commit's message. Nothing was sent")
|
|
}
|
|
files, given := args["files"]
|
|
if given && files != nil {
|
|
list, ok := files.([]any)
|
|
if !ok {
|
|
return a, refuse("files is a list of {path, content | content_base64 | delete: true}. Nothing was sent")
|
|
}
|
|
for i, item := range list {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
return a, refuse("files[%d] is not an object {path, content | content_base64 | delete: true}. Nothing was sent", i)
|
|
}
|
|
fc := FileChange{Path: text(m, "path")}
|
|
if v, ok := m["content"]; ok && v != nil {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return a, refuse("files[%d].content is text. Nothing was sent", i)
|
|
}
|
|
fc.Content = &s
|
|
}
|
|
if v, ok := m["content_base64"]; ok && v != nil {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return a, refuse("files[%d].content_base64 is text. Nothing was sent", i)
|
|
}
|
|
fc.ContentBase64 = &s
|
|
}
|
|
if v, ok := m["delete"]; ok && v != nil {
|
|
b, ok := v.(bool)
|
|
if !ok {
|
|
return a, refuse("files[%d].delete is true or false. Nothing was sent", i)
|
|
}
|
|
fc.Delete = b
|
|
}
|
|
a.Files = append(a.Files, fc)
|
|
}
|
|
}
|
|
switch {
|
|
case len(a.Files) == 0 && strings.TrimSpace(a.Patch) == "":
|
|
return a, refuse("give files (each {path, content | content_base64 | delete: true}) or patch (a unified diff, as git diff writes it). Nothing was sent")
|
|
case len(a.Files) > 0 && strings.TrimSpace(a.Patch) != "":
|
|
return a, refuse("give files or patch, not both: one commit carries one of them. Nothing was sent")
|
|
case len(a.Files) > maxFiles:
|
|
return a, refuse("%d files is more than the %d one commit carries. Nothing was sent", len(a.Files), maxFiles)
|
|
}
|
|
seen := map[string]bool{}
|
|
for i, fc := range a.Files {
|
|
if strings.HasPrefix(fc.Path, "/") {
|
|
return a, refuse("files[%d].path %q is relative to the repository's root, without a leading slash. Nothing was sent", i, fc.Path)
|
|
}
|
|
if _, err := segments("file path", fc.Path); err != nil {
|
|
return a, err
|
|
}
|
|
if seen[fc.Path] {
|
|
return a, refuse("%s is named twice in files. Nothing was sent", fc.Path)
|
|
}
|
|
seen[fc.Path] = true
|
|
given := 0
|
|
for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete} {
|
|
if g {
|
|
given++
|
|
}
|
|
}
|
|
if given != 1 {
|
|
return a, refuse("files[%d] (%s) needs exactly one of content, content_base64 or delete: true. Nothing was sent", i, fc.Path)
|
|
}
|
|
if fc.Content != nil && !utf8.ValidString(*fc.Content) {
|
|
return a, refuse("files[%d].content (%s) is not UTF-8 text: give it as content_base64. Nothing was sent", i, fc.Path)
|
|
}
|
|
if fc.ContentBase64 != nil {
|
|
if _, err := base64.StdEncoding.DecodeString(*fc.ContentBase64); err != nil {
|
|
return a, refuse("files[%d].content_base64 (%s) is not base64: %v. Nothing was sent", i, fc.Path, err)
|
|
}
|
|
}
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// targetRefused says why a commit may not go on a branch: the default branch, or one a protection rule names.
|
|
func targetRefused(target, defaultBranch string, rules []Protection) error {
|
|
if target == defaultBranch {
|
|
return refuse("a commit on %s, the repository's default branch, is refused: a change reaches it by a pull request "+
|
|
"that passes its checks (novox/hq ADR 0266). Put it on a branch of its own. Nothing was sent", target)
|
|
}
|
|
var guarding []string
|
|
for _, r := range rules {
|
|
if n := r.name(); n != "" && ruleCovers(n, target) {
|
|
guarding = append(guarding, n)
|
|
}
|
|
}
|
|
if len(guarding) > 0 {
|
|
return refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
|
|
"branch of its own. Nothing was sent", target, strings.Join(guarding, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// commit puts one commit on the asked branch, after the judgement, and reads the branch back.
|
|
func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
|
repo, err := repoPath(a.Owner, a.Repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := segments("branch", a.Branch); err != nil {
|
|
return nil, err
|
|
}
|
|
var r Repo
|
|
if err := f.getJSON(ctx, repo, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
if r.DefaultBranch == "" {
|
|
return nil, fmt.Errorf("the forge did not say %s/%s's default branch, so whether %s is it cannot be judged. Nothing was sent", a.Owner, a.Repo, a.Branch)
|
|
}
|
|
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
|
|
var rules []Protection
|
|
if err := f.getJSON(ctx, repo+"/branch_protections", &rules); err != nil {
|
|
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
|
|
}
|
|
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &Committed{Repository: a.Owner + "/" + a.Repo, Branch: a.Branch}
|
|
existing, err := f.branch(ctx, repo, a.Branch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref := a.Branch // where the files are read and the commit goes
|
|
if existing != nil {
|
|
out.Parent = existing.Commit.ID
|
|
if a.Base != "" {
|
|
out.Said = append(out.Said, fmt.Sprintf("%s exists, at %s: base %s was not used", a.Branch, short(existing.Commit.ID), a.Base))
|
|
}
|
|
} else {
|
|
out.Created = true
|
|
out.Base = a.Base
|
|
if out.Base == "" {
|
|
out.Base = r.DefaultBranch
|
|
}
|
|
ref = out.Base
|
|
b, err := f.branch(ctx, repo, out.Base)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if b == nil {
|
|
return nil, refuse("%s does not exist, and its base %s is not a branch of %s/%s. Nothing was sent", a.Branch, out.Base, a.Owner, a.Repo)
|
|
}
|
|
out.Parent = b.Commit.ID
|
|
// The files are judged at the commit just read; a file the base changes meanwhile is refused by the
|
|
// forge, whose update and delete carry the blob read here.
|
|
ref = b.Commit.ID
|
|
}
|
|
|
|
// Where the forge takes the commit: on the branch, or on a new one from the base's commit.
|
|
on := map[string]any{"message": a.Message}
|
|
if out.Created {
|
|
on["branch"] = out.Base
|
|
on["new_branch"] = a.Branch
|
|
} else {
|
|
on["branch"] = a.Branch
|
|
}
|
|
|
|
var made struct {
|
|
Commit struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"commit"`
|
|
}
|
|
if a.Patch != "" {
|
|
on["content"] = a.Patch
|
|
if err := f.sendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made); err != nil {
|
|
return nil, fmt.Errorf("the forge did not take the patch: %w", err)
|
|
}
|
|
} else {
|
|
var ops []map[string]any
|
|
for _, fc := range a.Files {
|
|
sha, err := f.blobAt(ctx, repo, fc.Path, ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
op := map[string]any{"path": fc.Path}
|
|
switch {
|
|
case fc.Delete && sha == "":
|
|
return nil, refuse("%s is to be deleted, but %s has no such file at %s. Nothing was sent", fc.Path, a.Owner+"/"+a.Repo, short(ref))
|
|
case fc.Delete:
|
|
op["operation"], op["sha"] = "delete", sha
|
|
case sha == "":
|
|
op["operation"] = "create"
|
|
default:
|
|
op["operation"], op["sha"] = "update", sha
|
|
}
|
|
if !fc.Delete {
|
|
if fc.Content != nil {
|
|
op["content"] = base64.StdEncoding.EncodeToString([]byte(*fc.Content))
|
|
} else {
|
|
op["content"] = *fc.ContentBase64
|
|
}
|
|
}
|
|
ops = append(ops, op)
|
|
out.Files = append(out.Files, CommittedFile{Path: fc.Path, Operation: op["operation"].(string)})
|
|
}
|
|
on["files"] = ops
|
|
if err := f.sendJSON(ctx, http.MethodPost, repo+"/contents", on, &made); err != nil {
|
|
return nil, fmt.Errorf("the forge did not take the commit: %w", err)
|
|
}
|
|
}
|
|
if made.Commit.SHA == "" {
|
|
return nil, fmt.Errorf("the forge answered the commit on %s without saying the commit it made: read the branch (gitea_list_branches) before trying again", a.Branch)
|
|
}
|
|
out.Commit = made.Commit.SHA
|
|
|
|
// Evidence over assertion: the branch is read back, and it must be at the commit the forge said.
|
|
after, err := f.branch(ctx, repo, a.Branch)
|
|
switch {
|
|
case err != nil:
|
|
return nil, fmt.Errorf("the forge made %s on %s, but the branch could not be read back: %w", out.Commit, a.Branch, err)
|
|
case after == nil:
|
|
return nil, fmt.Errorf("the forge said it made %s on %s, but the branch does not exist", out.Commit, a.Branch)
|
|
case after.Commit.ID != out.Commit:
|
|
return nil, fmt.Errorf("the forge said it made %s on %s, but the branch is at %s: something else moved it", out.Commit, a.Branch, after.Commit.ID)
|
|
}
|
|
what := "on"
|
|
if out.Created {
|
|
what = "on the new branch"
|
|
}
|
|
out.Said = append(out.Said, fmt.Sprintf("%s committed %s %s of %s, after %s; the branch is there now. A merge "+
|
|
"still needs a review at this head and both required checks green", short(out.Commit), what, a.Branch, out.Repository, short(out.Parent)))
|
|
return out, nil
|
|
}
|
|
|
|
// blobAt is the blob sha of a file at a ref, "" when there is no file there.
|
|
func (f *Forge) blobAt(ctx context.Context, repo, path, ref string) (string, error) {
|
|
seg, err := segments("file path", path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var entry struct {
|
|
Type string `json:"type"`
|
|
SHA string `json:"sha"`
|
|
}
|
|
var list []any
|
|
body, err := f.do(ctx, http.MethodGet, repo+"/contents/"+seg+"?ref="+queryEscape(ref), nil, maxBody)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
if jsonInto(body, &list) == nil {
|
|
return "", refuse("%s is a directory, not a file. Nothing was sent", path)
|
|
}
|
|
if err := jsonInto(body, &entry); err != nil {
|
|
return "", fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
|
|
}
|
|
if entry.Type != "file" && entry.Type != "symlink" {
|
|
return "", refuse("%s is a %s, not a file. Nothing was sent", path, entry.Type)
|
|
}
|
|
return entry.SHA, nil
|
|
}
|
|
|
|
func short(sha string) string {
|
|
if len(sha) > 12 {
|
|
return sha[:12]
|
|
}
|
|
return sha
|
|
}
|