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 }