From dc3a177410fa2683d0345bae17786a77da98b1f2 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:07:28 +0000 Subject: [PATCH] Let a commit copy files from another forge repository, so a large move is not typed through the caller --- modules/gitea/README.md | 2 +- modules/gitea/cmd/forge/commit.go | 31 ++- modules/gitea/cmd/forge/copy.go | 270 +++++++++++++++++++++++ modules/gitea/cmd/forge/copy_test.go | 295 ++++++++++++++++++++++++++ modules/gitea/cmd/forge/forge_test.go | 5 + modules/gitea/cmd/forge/main.go | 13 +- 6 files changed, 611 insertions(+), 5 deletions(-) create mode 100644 modules/gitea/cmd/forge/copy.go create mode 100644 modules/gitea/cmd/forge/copy_test.go diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 4770fb95..b9b8482c 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -44,7 +44,7 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag | tool | | what | |---|---|---| -| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent | +| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true \| copy_from}`; `copy_from` `{owner, repo, ref, path}` copies a file, or every file under a directory recursively to the same relative paths, of another forge repository, read by the module at one commit sha resolved from `ref` once and named per entry in the answer; files only, a symlink is refused; the copied files count against the 200 files, 4 MiB each and 16 MiB together, and a copy over them is refused naming the count and size, to be split by subdirectory) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent | | `gitea_commits` | r | a ref's history, newest first, optionally only commits touching a path: sha, author, date, subject | | `gitea_compare` | r | what head holds that base does not: its commits and the files they touch | | `gitea_pull_reviews` | r | a pull request's reviews — state, the commit reviewed and whether it is the head now, stale, dismissed — with their line comments | diff --git a/modules/gitea/cmd/forge/commit.go b/modules/gitea/cmd/forge/commit.go index f3b5e0df..66e4ced0 100644 --- a/modules/gitea/cmd/forge/commit.go +++ b/modules/gitea/cmd/forge/commit.go @@ -25,6 +25,8 @@ type FileChange struct { Content *string ContentBase64 *string Delete bool + CopyFrom *CopyFrom // copy_from (novox/hq issue 1365): the content of a file or directory of another repository + size int // the content's size in bytes, as committed } // CommitAsk is one gitea_commit call. @@ -51,6 +53,7 @@ type Committed struct { Parent string `json:"parent"` Commit string `json:"commit"` Files []CommittedFile `json:"files,omitempty"` + Copies []CopiedFrom `json:"copies,omitempty"` Said []string `json:"said"` } @@ -118,6 +121,16 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) { } fc.Delete = b } + if v, ok := m["copy_from"]; ok && v != nil { + c, err := readCopyFrom(i, v) + if err != nil { + return a, err + } + fc.CopyFrom = c + if fc.Content != nil || fc.ContentBase64 != nil || fc.Delete { + return a, refuse("files[%d] (%s) gives copy_from together with content, content_base64 or delete: it takes exactly one of them. Nothing was sent", i, fc.Path) + } + } a.Files = append(a.Files, fc) } } @@ -157,13 +170,13 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) { } seen[fc.Path] = true have := 0 - for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete} { + for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete, fc.CopyFrom != nil} { if g { have++ } } if have != 1 { - return a, refuse("files[%d] (%s) needs exactly one of content, content_base64 or delete: true. Nothing was sent", i, fc.Path) + return a, refuse("files[%d] (%s) needs exactly one of content, content_base64, delete: true or copy_from. 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) @@ -186,6 +199,7 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) { 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)) } + a.Files[i].size = size total += size } if total > maxCommitBytes { @@ -318,8 +332,19 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) { return nil, fmt.Errorf("the forge did not take the patch: %w", err) } } else { - var ops []map[string]any + files := a.Files for _, fc := range a.Files { + if fc.CopyFrom != nil { + // Read, and judged against the limits, before anything is sent. + var err error + if files, out.Copies, err = expandCopies(ctx, f, a); err != nil { + return nil, err + } + break + } + } + var ops []map[string]any + for _, fc := range files { sha, err := f.blobAt(ctx, repo, fc.Path, ref) if err != nil { return nil, err diff --git a/modules/gitea/cmd/forge/copy.go b/modules/gitea/cmd/forge/copy.go new file mode 100644 index 00000000..6cab2c96 --- /dev/null +++ b/modules/gitea/cmd/forge/copy.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// copy_from (novox/hq issue 1365): a file or a directory of another repository of the forge, copied by the +// module itself, so a large move between repositories is not typed through the caller. Read through the +// forge's API as the module's reads are (the contents of a path at a commit), every file at one commit sha +// resolved once; the commit carries the files as it carries any others, within the same limits. + +// CopyFrom is where a files entry takes its content from. +type CopyFrom struct { + Owner, Repo, Ref, Path string +} + +// CopiedFrom says, for one copy_from entry, what was read and at which commit. +type CopiedFrom struct { + Path string `json:"path"` + From string `json:"from"` + Source string `json:"source_commit"` + Files int `json:"files"` + Bytes int `json:"bytes"` +} + +var fullSHA = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// readCopyFrom reads one files[i].copy_from. +func readCopyFrom(i int, v any) (*CopyFrom, error) { + m, ok := v.(map[string]any) + if !ok { + return nil, refuse("files[%d].copy_from is an object {owner, repo, ref, path}. Nothing was sent", i) + } + c := &CopyFrom{Owner: text(m, "owner"), Repo: text(m, "repo"), Ref: text(m, "ref"), Path: strings.Trim(text(m, "path"), "/")} + if c.Owner == "" || c.Repo == "" || c.Ref == "" || c.Path == "" { + return nil, refuse("files[%d].copy_from needs owner, repo, ref (a branch, tag or commit) and path. Nothing was sent", i) + } + if _, err := repoPath(c.Owner, c.Repo); err != nil { + return nil, err + } + if _, err := segments("copy_from path", c.Path); err != nil { + return nil, err + } + return c, nil +} + +// copySource is one file to copy: where it goes, its content (base64 as the forge answered, once read) and size. +type copySource struct { + target, source string + size int +} + +// resolveRef is the full commit sha a ref names in a repository, "" when there is none. +func (f *Forge) resolveRef(ctx context.Context, repo, ref string) (string, error) { + if fullSHA.MatchString(ref) { + return ref, nil + } + if _, err := segments("copy_from ref", ref); err != nil { + return "", err + } + b, err := f.branch(ctx, repo, ref) + if err != nil { + return "", err + } + if b != nil && b.Commit.ID != "" { + return b.Commit.ID, nil + } + // Not a branch: a tag or a short commit, as the commit history reads it. + var got []forgeCommit + if err := f.GetJSON(ctx, repo+"/commits?sha="+queryEscape(ref)+"&limit=1&stat=false&verification=false&files=false", &got); err != nil { + if errors.Is(err, errNotFound) { + return "", nil + } + return "", err + } + if len(got) == 0 { + return "", nil + } + return got[0].SHA, nil +} + +// contentEntry is one entry of the forge's answer for a path: a file or a symlink, or the entries of a directory. +type contentEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size"` + Encoding string `json:"encoding"` + Content string `json:"content"` +} + +// contentsAt reads a path at a commit: a single entry for a file, a list for a directory; found false for none. +func (f *Forge) contentsAt(ctx context.Context, repo, path, sha string) (one contentEntry, list []contentEntry, found bool, err error) { + seg, err := segments("copy_from path", path) + if err != nil { + return one, nil, false, err + } + body, err := f.Do(ctx, "GET", repo+"/contents/"+seg+"?ref="+queryEscape(sha), nil, maxBody) + if err != nil { + if errors.Is(err, errNotFound) { + return one, nil, false, nil + } + return one, nil, false, err + } + if jsonInto(body, &list) == nil { + return one, list, true, nil + } + if err := jsonInto(body, &one); err != nil { + return one, nil, false, fmt.Errorf("the forge's answer about %s is not readable: %w", path, err) + } + return one, nil, true, nil +} + +// walkCopy lists every file under a directory at a commit, recursively; a symlink or anything not a file is refused. +func (f *Forge) walkCopy(ctx context.Context, repo, label string, dir string, list []contentEntry, out *[]copySource, prefix, target string) error { + for _, e := range list { + p := e.Path + if p == "" { + p = dir + "/" + e.Name + } + switch e.Type { + case "file": + rel := strings.TrimPrefix(strings.TrimPrefix(p, prefix), "/") + *out = append(*out, copySource{target: target + "/" + rel, source: p, size: int(e.Size)}) + case "dir": + _, sub, found, err := f.contentsAt(ctx, repo, p, label) + if err != nil { + return err + } + if !found { + return fmt.Errorf("the forge listed %s but then did not have it", p) + } + if err := f.walkCopy(ctx, repo, label, p, sub, out, prefix, target); err != nil { + return err + } + case "symlink": + return refuse("%s is a symlink: copy_from copies files only. Nothing was sent", p) + default: + return refuse("%s is a %s: copy_from copies files only. Nothing was sent", p, e.Type) + } + } + return nil +} + +// expandCopies replaces every copy_from entry by the files it stands for, read at one commit per entry, and says +// what was read. The limits count the copied files with the others before anything is read in full. +func expandCopies(ctx context.Context, f *Forge, a CommitAsk) ([]FileChange, []CopiedFrom, error) { + type plan struct { + at int + sha string + sources []copySource + from *CopyFrom + repo string + } + var plans []plan + count, total := 0, 0 + for i, fc := range a.Files { + if fc.CopyFrom == nil { + count++ + total += fc.size + continue + } + c := fc.CopyFrom + repo, err := repoPath(c.Owner, c.Repo) + if err != nil { + return nil, nil, err + } + var r Repo + if err := f.GetJSON(ctx, repo, &r); err != nil { + if errors.Is(err, errNotFound) { + return nil, nil, refuse("files[%d].copy_from: the forge has no repository %s/%s. Nothing was sent", i, c.Owner, c.Repo) + } + return nil, nil, err + } + sha, err := f.resolveRef(ctx, repo, c.Ref) + if err != nil { + return nil, nil, err + } + if sha == "" { + return nil, nil, refuse("files[%d].copy_from: %s/%s has no branch, tag or commit %q. Nothing was sent", i, c.Owner, c.Repo, c.Ref) + } + one, list, found, err := f.contentsAt(ctx, repo, c.Path, sha) + if err != nil { + return nil, nil, err + } + if !found { + return nil, nil, refuse("files[%d].copy_from: %s/%s has no %q at %s. Nothing was sent", i, c.Owner, c.Repo, c.Path, short(sha)) + } + p := plan{at: i, sha: sha, from: c, repo: repo} + if list != nil || one.Type == "dir" { + if err := f.walkCopy(ctx, repo, sha, c.Path, list, &p.sources, c.Path, fc.Path); err != nil { + return nil, nil, err + } + } else if one.Type == "file" { + p.sources = []copySource{{target: fc.Path, source: c.Path, size: int(one.Size)}} + } else { + return nil, nil, refuse("files[%d].copy_from: %s is a %s, not a file or a directory: copy_from copies files only. Nothing was sent", i, c.Path, one.Type) + } + if len(p.sources) == 0 { + return nil, nil, refuse("files[%d].copy_from: %s/%s has no file under %q at %s. Nothing was sent", i, c.Owner, c.Repo, c.Path, short(sha)) + } + for _, s := range p.sources { + count++ + total += s.size + if s.size > maxFileBytes { + return nil, nil, refuse("files[%d].copy_from: %s is %s, more than the %s one file of a commit may be. Nothing was sent", i, s.source, mib(s.size), mib(maxFileBytes)) + } + } + plans = append(plans, p) + } + if count > maxFiles || total > maxCommitBytes { + return nil, nil, refuse("the commit would carry %d files, %s together (copied and given), more than the %d files and %s one commit carries: "+ + "split the copy by subdirectory into several commits. Nothing was sent", count, mib(total), maxFiles, mib(maxCommitBytes)) + } + // The files are read now, each at its commit sha. + var out []FileChange + var said []CopiedFrom + seen := map[string]bool{} + for _, fc := range a.Files { + if fc.CopyFrom == nil { + if seen[fc.Path] { + return nil, nil, refuse("%s is named twice in files. Nothing was sent", fc.Path) + } + seen[fc.Path] = true + out = append(out, fc) + } + } + for _, p := range plans { + cf := CopiedFrom{Path: a.Files[p.at].Path, From: p.from.Owner + "/" + p.from.Repo + ":" + p.from.Path, Source: p.sha} + sort.Slice(p.sources, func(i, j int) bool { return p.sources[i].target < p.sources[j].target }) + for _, s := range p.sources { + if seen[s.target] { + return nil, nil, refuse("%s is named twice in files (a copy_from reaches it as well). Nothing was sent", s.target) + } + seen[s.target] = true + if _, err := segments("file path", s.target); err != nil { + return nil, nil, err + } + one, _, found, err := f.contentsAt(ctx, p.repo, s.source, p.sha) + if err != nil { + return nil, nil, err + } + if !found || one.Type != "file" { + return nil, nil, refuse("%s/%s: %s is not a file at %s any more. Nothing was sent", p.from.Owner, p.from.Repo, s.source, short(p.sha)) + } + if one.Encoding != "base64" { + return nil, nil, fmt.Errorf("the forge answered %s without its content in base64 (encoding %q): nothing was sent", s.source, one.Encoding) + } + b64 := strings.NewReplacer("\n", "", "\r", "").Replace(one.Content) + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, nil, fmt.Errorf("the forge's content of %s is not base64: %w", s.source, err) + } + if len(raw) > maxFileBytes { + return nil, nil, refuse("%s is %s, more than the %s one file of a commit may be. Nothing was sent", s.source, mib(len(raw)), mib(maxFileBytes)) + } + cf.Files++ + cf.Bytes += len(raw) + out = append(out, FileChange{Path: s.target, ContentBase64: &b64, size: len(raw)}) + } + said = append(said, cf) + } + return out, said, nil +} diff --git a/modules/gitea/cmd/forge/copy_test.go b/modules/gitea/cmd/forge/copy_test.go new file mode 100644 index 00000000..23e65cf5 --- /dev/null +++ b/modules/gitea/cmd/forge/copy_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "testing" +) + +// sourceRepo is another repository of the fake forge, answering the contents, branch, repository and commit +// reads in the shapes the module already parses elsewhere (forge.go, reads.go, commit.go); a file's content +// is the forge's base64 "content" with its "encoding". +type sourceRepo struct { + branches map[string]string + trees map[string]map[string]string // commit -> path -> content + symlinks map[string]bool // paths that are symlinks, in every tree + moveTo map[string]string // branch -> commit it moves to right after it is first read + tags map[string]string + reads []string +} + +func (f *fakeForge) addSource(name string, s *sourceRepo) { + if f.sources == nil { + f.sources = map[string]*sourceRepo{} + } + f.sources[name] = s +} + +func (f *fakeForge) handleSource(w http.ResponseWriter, r *http.Request, path string) { + rest := strings.TrimPrefix(path, "/api/v1/repos/acme/") + name, rest, _ := strings.Cut(rest, "/") + s := f.sources[name] + if s == nil { + http.NotFound(w, r) + return + } + reply := func(v any) { _ = json.NewEncoder(w).Encode(v) } + s.reads = append(s.reads, r.Method+" "+path+"?"+r.URL.RawQuery) + switch { + case rest == "": + reply(map[string]any{"full_name": "acme/" + name, "default_branch": "main"}) + case strings.HasPrefix(rest, "branches/"): + b := strings.TrimPrefix(rest, "branches/") + c, ok := s.branches[b] + if !ok { + http.NotFound(w, r) + return + } + reply(map[string]any{"name": b, "commit": map[string]any{"id": c}}) + if to, ok := s.moveTo[b]; ok { + delete(s.moveTo, b) + s.branches[b] = to + } + case rest == "commits": + if c, ok := s.tags[r.URL.Query().Get("sha")]; ok { + reply([]any{map[string]any{"sha": c}}) + return + } + reply([]any{}) + case strings.HasPrefix(rest, "contents/"): + p := strings.TrimPrefix(rest, "contents/") + tree, ok := s.trees[r.URL.Query().Get("ref")] + if !ok { + http.NotFound(w, r) + return + } + if content, ok := tree[p]; ok { + typ := "file" + if s.symlinks[p] { + typ = "symlink" + } + reply(map[string]any{"type": typ, "path": p, "name": p[strings.LastIndex(p, "/")+1:], "sha": blob(content), + "size": len(content), "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content))}) + return + } + // A directory: its entries, each with its full path. + entries := map[string]map[string]any{} + for q, content := range tree { + if !strings.HasPrefix(q, p+"/") { + continue + } + sub := strings.TrimPrefix(q, p+"/") + first, more, nested := strings.Cut(sub, "/") + _ = more + if nested { + entries[first] = map[string]any{"name": first, "path": p + "/" + first, "type": "dir"} + continue + } + typ := "file" + if s.symlinks[q] { + typ = "symlink" + } + entries[first] = map[string]any{"name": first, "path": q, "type": typ, "size": len(content), "sha": blob(content)} + } + if len(entries) == 0 { + http.NotFound(w, r) + return + } + var names []string + for n := range entries { + names = append(names, n) + } + sort.Strings(names) + list := []any{} + for _, n := range names { + list = append(list, entries[n]) + } + reply(list) + default: + http.NotFound(w, r) + } +} + +func newSource() *sourceRepo { + return &sourceRepo{ + branches: map[string]string{"main": "s0"}, + tags: map[string]string{"v1": "s0"}, + trees: map[string]map[string]string{ + "s0": {"lib/a.go": "package a\n", "lib/deep/b.go": "package b\n", "lib/deep/er/c.txt": "c\n", "top.md": "top\n", "other/x": "x\n"}, + }, + } +} + +func copyAsk(t *testing.T, files ...map[string]any) CommitAsk { + return ask(t, map[string]any{"branch": "fix/1365-x", "files": filesArg(files...)}) +} + +func cp(path, repo, ref, src string) map[string]any { + return map[string]any{"path": path, "copy_from": map[string]any{"owner": "acme", "repo": repo, "ref": ref, "path": src}} +} + +// Seen failing first: with the expansion switched off the commit carried no copy and the answer named no source. +func TestACopyFromAFileCommitsTheSourcesContentAndNamesItsCommit(t *testing.T) { + f := newFake(t) + f.addSource("src", newSource()) + got, err := commit(context.Background(), f.serve(), copyAsk(t, cp("vendor/top.md", "src", "main", "top.md"))) + if err != nil { + t.Fatal(err) + } + if f.trees[got.Commit]["vendor/top.md"] != "top\n" { + t.Fatalf("tree %v", f.trees[got.Commit]) + } + if len(got.Copies) != 1 || got.Copies[0].Source != "s0" || got.Copies[0].Path != "vendor/top.md" || got.Copies[0].Files != 1 { + t.Fatalf("copies %+v", got.Copies) + } +} + +// Seen failing first, as above. +func TestACopyFromADirectoryCopiesEveryFileUnderItToTheSameRelativePaths(t *testing.T) { + f := newFake(t) + f.addSource("src", newSource()) + got, err := commit(context.Background(), f.serve(), copyAsk(t, cp("pkg/lib", "src", "v1", "lib"), + map[string]any{"path": "own.md", "content": "own\n"})) + if err != nil { + t.Fatal(err) + } + tree := f.trees[got.Commit] + want := map[string]string{"pkg/lib/a.go": "package a\n", "pkg/lib/deep/b.go": "package b\n", "pkg/lib/deep/er/c.txt": "c\n", "own.md": "own\n"} + for p, c := range want { + if tree[p] != c { + t.Errorf("%s is %q, want %q", p, tree[p], c) + } + } + if tree["pkg/lib/top.md"] != "" || tree["pkg/other/x"] != "" { + t.Errorf("files outside the directory were copied: %v", tree) + } + if len(got.Copies) != 1 || got.Copies[0].Source != "s0" || got.Copies[0].Files != 3 || got.Copies[0].Bytes != len("package a\npackage b\nc\n") { + t.Fatalf("copies %+v", got.Copies) + } + if len(got.Files) != 4 { + t.Errorf("files %+v", got.Files) + } +} + +// Seen failing first, as above: the files were read at the branch's new head. +func TestEveryCopiedFileIsReadAtTheOneShaTheRefResolvedTo(t *testing.T) { + f := newFake(t) + s := newSource() + s.trees["s1"] = map[string]string{"lib/a.go": "package MOVED\n", "lib/deep/b.go": "package MOVED\n", "lib/deep/er/c.txt": "MOVED\n"} + s.moveTo = map[string]string{"main": "s1"} + f.addSource("src", s) + got, err := commit(context.Background(), f.serve(), copyAsk(t, cp("pkg", "src", "main", "lib"))) + if err != nil { + t.Fatal(err) + } + if f.trees[got.Commit]["pkg/a.go"] != "package a\n" || got.Copies[0].Source != "s0" { + t.Fatalf("tree %v, copies %+v", f.trees[got.Commit], got.Copies) + } + for _, r := range s.reads { + if strings.Contains(r, "/contents/") && !strings.Contains(r, "ref=s0") { + t.Errorf("a file was read at another ref: %s", r) + } + } +} + +// Seen failing first: each case was taken or refused for another reason (or sent). +func TestEveryRefusalOfACopySendsNothingAndSaysWhy(t *testing.T) { + big := strings.Repeat("z", maxFileBytes+1) + many := map[string]string{} + for i := 0; i < maxFiles+1; i++ { + many[fmt.Sprintf("many/f%03d", i)] = "m" + } + for name, c := range map[string]struct { + file map[string]any + want string + }{ + "unknown repository": {cp("t", "nope", "main", "top.md"), "no repository acme/nope"}, + "unknown ref": {cp("t", "src", "nothing", "top.md"), `no branch, tag or commit "nothing"`}, + "unknown path": {cp("t", "src", "main", "missing/dir"), `no "missing/dir"`}, + "symlink in source": {cp("t", "src", "main", "link"), "link is a symlink"}, + "symlink under dir": {cp("t", "src", "main", "withlink"), "withlink/l is a symlink"}, + "file over 4 MiB": {cp("t", "src", "main", "big"), "more than the 4.0 MiB"}, + "over 200 files": {cp("t", "src", "main", "many"), "201 files"}, + "with content": {map[string]any{"path": "t", "content": "x", "copy_from": cp("t", "src", "main", "top.md")["copy_from"]}, "copy_from together with content"}, + "with base64": {map[string]any{"path": "t", "content_base64": "eA==", "copy_from": cp("t", "src", "main", "top.md")["copy_from"]}, "copy_from together with content"}, + "with delete": {map[string]any{"path": "t", "delete": true, "copy_from": cp("t", "src", "main", "top.md")["copy_from"]}, "copy_from together with content"}, + "meets a given file": {cp("pkg", "src", "main", "lib"), "pkg/a.go is named twice"}, + } { + f := newFake(t) + s := newSource() + s.trees["s0"]["link"] = "top.md" + s.symlinks = map[string]bool{"link": true, "withlink/l": true} + s.trees["s0"]["withlink/l"] = "top.md" + s.trees["s0"]["withlink/ok"] = "ok" + s.trees["s0"]["big/f"] = big + for p, v := range many { + s.trees["s0"][p] = v + } + f.addSource("src", s) + files := []map[string]any{c.file} + if name == "meets a given file" { + files = append(files, map[string]any{"path": "pkg/a.go", "content": "mine"}) + } + a, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "fix/1365-x", "files": filesArg(files...)}) + if err == nil { + _, err = commit(context.Background(), f.serve(), a) + } + if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "Nothing was sent") { + t.Errorf("%s: %v (want %q)", name, err, c.want) + } + if len(f.writes) != 0 || len(f.patches) != 0 || f.branches["fix/1365-x"] != "" { + t.Errorf("%s: something was sent: %v %v", name, f.writes, f.branches) + } + } +} + +// Seen failing first: the copy was not counted, so the commit was sent. +func TestACopyOverTheTotalNamesItsCountAndSizeSoItCanBeSplit(t *testing.T) { + f := newFake(t) + s := newSource() + chunk := strings.Repeat("q", maxFileBytes) + for i := 0; i < 5; i++ { + s.trees["s0"][fmt.Sprintf("heavy/f%d", i)] = chunk + } + f.addSource("src", s) + _, err := commit(context.Background(), f.serve(), copyAsk(t, cp("h", "src", "main", "heavy"))) + if err == nil || !strings.Contains(err.Error(), "5 files, 20.0 MiB together") || !strings.Contains(err.Error(), "by subdirectory") { + t.Fatalf("%v", err) + } + if len(f.writes) != 0 { + t.Fatal("sent") + } +} + +func TestCopyFromIsInTheToolsSchemaAndTheReadme(t *testing.T) { + var schema map[string]any + for _, tl := range Tools(func() (*Forge, error) { return nil, fmt.Errorf("unset") }) { + if tl.Name == "gitea_commit" { + schema = tl.Input + } + } + item := schema["properties"].(map[string]any)["files"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any) + cf, ok := item["copy_from"].(map[string]any) + if !ok { + t.Fatalf("copy_from is not in the schema: %v", item) + } + props := cf["properties"].(map[string]any) + for _, k := range []string{"owner", "repo", "ref", "path"} { + if _, ok := props[k]; !ok { + t.Errorf("copy_from has no %s", k) + } + } +} + +func TestACopyFromThatIsNotAnObjectIsRefused(t *testing.T) { + _, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b", + "files": filesArg(map[string]any{"path": "x", "copy_from": "acme/src:top.md"})}) + if err == nil || !strings.Contains(err.Error(), "copy_from is an object") { + t.Fatalf("%v", err) + } +} diff --git a/modules/gitea/cmd/forge/forge_test.go b/modules/gitea/cmd/forge/forge_test.go index 1d315cc3..1d4eb928 100644 --- a/modules/gitea/cmd/forge/forge_test.go +++ b/modules/gitea/cmd/forge/forge_test.go @@ -45,6 +45,7 @@ type fakeForge struct { noParents bool // the forge's answer to a write does not say the parents parentsBroken bool // and GET git/commits fails parents map[string]string // commit -> parent, as GET git/commits says it + sources map[string]*sourceRepo } type move struct { @@ -88,6 +89,10 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) { reply := func(v any) { _ = json.NewEncoder(w).Encode(v) } const repo = "/api/v1/repos/acme/app" path := r.URL.EscapedPath() + if strings.HasPrefix(path, "/api/v1/repos/acme/") && !strings.HasPrefix(path, repo) { + f.handleSource(w, r, path) + return + } switch { case r.Method == "GET" && path == repo: reply(map[string]any{"full_name": "acme/app", "default_branch": "main"}) diff --git a/modules/gitea/cmd/forge/main.go b/modules/gitea/cmd/forge/main.go index 6388f096..3be08aa8 100644 --- a/modules/gitea/cmd/forge/main.go +++ b/modules/gitea/cmd/forge/main.go @@ -51,7 +51,7 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { "when it does not exist. Refused on the repository's default branch and on any branch a protection " + "rule names (novox/hq ADR 0266): a change reaches those only by a pull request whose head an " + "independent review approved and whose required checks passed (gitea_merge_pull_request). Carries " + - "either files — each {path, content (text) | content_base64 | delete: true}, created or updated as " + + "either files — each {path, content (text) | content_base64 | delete: true | copy_from {owner, repo, ref, path}: a file or directory of another repository, copied by the module}, created or updated as " + "the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " + "At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " + "the parent the forge made it on, read back from the branch, and says when the branch moved under " + @@ -77,6 +77,17 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { "content": str("its whole new content, as UTF-8 text"), "content_base64": str("its whole new content, base64-encoded, for a file that is not text"), "delete": map[string]any{"type": "boolean", "description": "true to delete the file"}, + "copy_from": map[string]any{ + "type": "object", + "description": "instead of content: copy a file, or every file under a directory (recursively, to the same relative paths under this path), of another repository of the forge, read at one commit sha resolved from ref once (the answer names it per entry). Files only: a symlink is refused. The copied files count against the limits; an unknown repository, ref or path, or a copy over the limits, is refused with nothing sent, naming the count and size so the copy can be split by subdirectory. Not together with content, content_base64 or delete", + "properties": map[string]any{ + "owner": str("the source repository's owner"), + "repo": str("the source repository's name"), + "ref": str("a branch, tag or commit of the source repository"), + "path": str("the file or directory to copy, from the source repository's root"), + }, + "required": []string{"owner", "repo", "path", "ref"}, + }, }, "required": []string{"path"}, }, -- 2.54.0