The forge's contents and diffpatch endpoints take a branch, never a commit to
build on, so the parent is read from the commit the forge made, and a branch
that moved under the commit is said. Blobs are read at the commit read first,
not at the branch name. Protection rules match as Gitea 1.27 matches them
([...], {a,b}, escapes, a plain name in any case), in the Go bundle and in
guard.ts alike. One commit carries at most 4 MiB per file or patch and 16 MiB
together; code search stops at its deadline and says so. The HTTP client the
two Go bundles copied is one package, internal/forgehttp.
407 lines
15 KiB
Go
407 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"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"`
|
|
}
|
|
|
|
// What one commit carries at most (novox/hq issue 539): files, one file's content, one patch, and every
|
|
// file's content together, in bytes as committed (content_base64 decoded). Larger work is several commits.
|
|
const (
|
|
maxFiles = 200
|
|
maxFileBytes = 4 << 20
|
|
maxPatchBytes = 4 << 20
|
|
maxCommitBytes = 16 << 20
|
|
)
|
|
|
|
// 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)
|
|
case len(a.Patch) > maxPatchBytes:
|
|
return a, refuse("the patch is %s, more than the %s one commit carries: split it into several commits. Nothing was sent", mib(len(a.Patch)), mib(maxPatchBytes))
|
|
}
|
|
seen := map[string]bool{}
|
|
total := 0
|
|
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)
|
|
}
|
|
size := 0
|
|
switch {
|
|
case fc.Content != nil:
|
|
size = len(*fc.Content)
|
|
case fc.ContentBase64 != nil:
|
|
if base64.StdEncoding.DecodedLen(len(*fc.ContentBase64)) > maxFileBytes+3 {
|
|
size = base64.StdEncoding.DecodedLen(len(*fc.ContentBase64))
|
|
break
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(*fc.ContentBase64)
|
|
if err != nil {
|
|
return a, refuse("files[%d].content_base64 (%s) is not base64: %v. Nothing was sent", i, fc.Path, err)
|
|
}
|
|
size = len(raw)
|
|
}
|
|
if size > maxFileBytes {
|
|
return a, refuse("files[%d] (%s) is %s, more than the %s one file of a commit may be. Nothing was sent", i, fc.Path, mib(size), mib(maxFileBytes))
|
|
}
|
|
total += size
|
|
}
|
|
if total > maxCommitBytes {
|
|
return a, refuse("the files are %s together, more than the %s one commit carries: split them into several commits. Nothing was sent", mib(total), mib(maxCommitBytes))
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// mib says a size in bytes in MiB, to a tenth.
|
|
func mib(n int) string { return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) }
|
|
|
|
// 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
|
|
}
|
|
// The files are judged at the commit just read, never at a branch name that could move between two
|
|
// reads (novox/hq issue 539): a file the branch changes meanwhile is refused by the forge, whose update
|
|
// and delete carry the blob read here.
|
|
var ref string
|
|
if existing != nil {
|
|
out.Parent = existing.Commit.ID
|
|
ref = 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
|
|
}
|
|
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
|
|
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
|
|
}
|
|
|
|
// The forge takes no commit to build on, only a branch (Gitea 1.27: neither /contents nor /diffpatch
|
|
// carries a last commit), so the parent is read from what it made, never assumed (novox/hq issue 539).
|
|
var made struct {
|
|
Commit madeCommit `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
|
|
read := out.Parent
|
|
parent, err := f.parentOf(ctx, repo, made.Commit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the forge made %s on %s, but which commit it went on could not be read: %w", out.Commit, a.Branch, err)
|
|
}
|
|
out.Parent = parent
|
|
if parent != read {
|
|
moved := "branch " + a.Branch
|
|
if out.Created {
|
|
moved = "base " + out.Base
|
|
}
|
|
how := "each file it changes was checked against " + short(read) + " by its blob, so none of them changed in between"
|
|
if a.Patch != "" {
|
|
how = "the forge applied the patch there, and it refuses a patch whose context changed"
|
|
}
|
|
out.Said = append(out.Said, fmt.Sprintf("the %s moved from %s to %s between the read and the commit, so the "+
|
|
"commit went on %s: %s. What else changed in between: gitea_compare %s...%s",
|
|
moved, short(read), short(parent), short(parent), how, short(read), short(parent)))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// madeCommit is the commit the forge answers a write with.
|
|
type madeCommit struct {
|
|
SHA string `json:"sha"`
|
|
Parents []struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"parents"`
|
|
}
|
|
|
|
// parentOf is the commit the forge made its commit on: from its answer, or read from the commit when the
|
|
// answer does not say it.
|
|
func (f *Forge) parentOf(ctx context.Context, repo string, made madeCommit) (string, error) {
|
|
if len(made.Parents) == 0 {
|
|
var read madeCommit
|
|
if err := f.GetJSON(ctx, repo+"/git/commits/"+url.PathEscape(made.SHA)+"?stat=false&verification=false&files=false", &read); err != nil {
|
|
return "", err
|
|
}
|
|
made = read
|
|
}
|
|
if len(made.Parents) != 1 || made.Parents[0].SHA == "" {
|
|
return "", fmt.Errorf("the forge says %s has %d parents, not one", made.SHA, len(made.Parents))
|
|
}
|
|
return made.Parents[0].SHA, 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
|
|
}
|