// forge: the forge module's Go bundle for an agent's commit and the reads around it (novox/hq issue 375). // A process the node's runtime launches and speaks MCP over stdio to, beside the module's TypeScript bundle // and its npm-registry bundle. gitea_commit puts one commit on a branch of its own — never the default branch, // never one a protection rule names (novox/hq ADR 0266) — and the reads answer a commit history, a compare, a // pull request's reviews with their line comments, a directory's entries and a code search, summarised. // stdout is the protocol; it says what it says on stderr, and never the forge's credential. package main import ( "context" "fmt" "math" "os" "strings" stdio "git.novox.be/novox/mesh-sdk/go" ) // ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the npm-registry // bundle for why); the README names these, and a test holds the three together. var ToolNames = []string{"gitea_commit", "gitea_commits", "gitea_compare", "gitea_pull_reviews", "gitea_list_dir", "gitea_search_code"} func main() { if err := stdio.Serve("", Tools(ForgeFromEnv)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } // Tools are the bundle's tools over the forge the function gives; asked per call, so a manifest that does not // set the environment is a refusal naming what is missing, not a bundle that will not start. func Tools(forge func() (*Forge, error)) []stdio.Tool { str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} } owner := str("the repository's owner, e.g. novox") repo := str("the repository's name, e.g. hq") run := func(do func(ctx context.Context, f *Forge, args map[string]any) (any, error)) func(map[string]any) (any, error) { return func(args map[string]any) (any, error) { f, err := forge() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), deadline) defer cancel() return do(ctx, f, args) } } return []stdio.Tool{ { Name: "gitea_commit", Description: "Put one commit on a branch of its own in a forge repository, creating the branch from base " + "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 " + "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 " + "it. Replaces git commit and git " + "push of a feature branch. (a)", Input: map[string]any{ "type": "object", "properties": map[string]any{ "owner": owner, "repo": repo, "branch": str("the branch the commit goes on, e.g. fix/375-a-gitea-commit-tool; never the default branch or a protected one"), "base": str("for a branch that does not exist yet: the branch it starts from (default the repository's default branch)"), "message": str("the commit's message: concise, in the imperative, about why"), "files": map[string]any{ "type": "array", "description": "the files the commit changes; give this or patch", "items": map[string]any{ "type": "object", "properties": map[string]any{ "path": str("the file's path from the repository's root"), "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"}, }, "required": []string{"path"}, }, }, "patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this or files"), }, "required": []string{"owner", "repo", "branch", "message"}, }, Run: run(func(ctx context.Context, f *Forge, args map[string]any) (any, error) { a, err := readCommitAsk(args) if err != nil { return nil, err } return commit(ctx, f, a) }), }, { Name: "gitea_commits", Description: "A repository's commit history at a branch, tag or commit (default the default branch), " + "newest first, optionally only the commits touching a path: each commit's sha, author, date and " + "subject line. Replaces git log. (r)", Input: map[string]any{ "owner": owner, "repo": repo, "ref": str("a branch, tag or commit (default the default branch)"), "path": str("only commits touching this file or directory"), "limit": map[string]any{"type": "integer", "description": "commits per page, 1 to 100 (default 20)"}, "page": map[string]any{"type": "integer", "description": "the page, from 1 (default 1)"}, }, Run: run(func(ctx context.Context, f *Forge, args map[string]any) (any, error) { limit, err := count(args, "limit", 20, 1, 100) if err != nil { return nil, err } page, err := count(args, "page", 1, 1, 10000) if err != nil { return nil, err } return history(ctx, f, text(args, "owner"), text(args, "repo"), text(args, "ref"), strings.Trim(text(args, "path"), "/"), limit, page) }), }, { Name: "gitea_compare", Description: "What head holds that base does not, in a forge repository: its commits (sha, author, date, " + "subject) and the files they touch with how. Replaces git log base..head and git diff --stat. (r)", Input: map[string]any{ "owner": owner, "repo": repo, "base": str("the branch, tag or commit compared from, e.g. main"), "head": str("the branch, tag or commit compared, e.g. fix/375-a-gitea-commit-tool"), }, Run: run(func(ctx context.Context, f *Forge, args map[string]any) (any, error) { return compare(ctx, f, text(args, "owner"), text(args, "repo"), text(args, "base"), text(args, "head")) }), }, { Name: "gitea_pull_reviews", Description: "A pull request's reviews, oldest first: who, its state (APPROVED, REQUEST_CHANGES, COMMENT, " + "PENDING), the commit it reviewed and whether that is the pull request's head now, stale or dismissed, " + "its body, and every line comment it left (file, line, body, who resolved it). (r)", Input: map[string]any{ "owner": owner, "repo": repo, "number": map[string]any{"type": "integer", "description": "the pull request's number"}, }, Run: run(func(ctx context.Context, f *Forge, args map[string]any) (any, error) { n, err := count(args, "number", 0, 1, math.MaxInt32) if err != nil { return nil, err } if n == 0 { return nil, refuse("number is required: the pull request's number") } return reviews(ctx, f, text(args, "owner"), text(args, "repo"), n) }), }, { Name: "gitea_list_dir", Description: "A directory's entries in a forge repository at a branch, tag or commit (default the default " + "branch): each entry's name, its type (file, dir, symlink, submodule) and a file's size. Replaces ls " + "and git ls-tree. (r)", Input: map[string]any{ "owner": owner, "repo": repo, "path": str("the directory from the repository's root (default the root)"), "ref": str("a branch, tag or commit (default the default branch)"), }, Run: run(func(ctx context.Context, f *Forge, args map[string]any) (any, error) { return listDir(ctx, f, text(args, "owner"), text(args, "repo"), text(args, "path"), text(args, "ref")) }), }, { Name: "gitea_search_code", Description: "Search a forge repository's text files at a branch, tag or commit (default the default " + "branch) for a literal string, or a regular expression with regex: true, optionally under a path: " + "each matching line's file, number and text. The forge has no code search of its own, so the " + "repository's archive is read once and searched; files over 4 MiB are named, not searched. Replaces " + "git grep. (r)", Input: map[string]any{ "owner": owner, "repo": repo, "query": str("what to find: a literal string, or a Go regular expression with regex: true"), "regex": map[string]any{"type": "boolean", "description": "true to read query as a regular expression"}, "ignore_case": map[string]any{"type": "boolean", "description": "true to match regardless of case"}, "path": str("only files under this directory, or this one file"), "ref": str("a branch, tag or commit (default the default branch)"), "max": map[string]any{"type": "integer", "description": "the most matches answered, 1 to 1000 (default 100)"}, }, Run: run(func(ctx context.Context, f *Forge, args map[string]any) (any, error) { max, err := count(args, "max", 100, 1, 1000) if err != nil { return nil, err } regex, err := flag(args, "regex") if err != nil { return nil, err } ignore, err := flag(args, "ignore_case") if err != nil { return nil, err } return searchCode(ctx, f, SearchAsk{Owner: text(args, "owner"), Repo: text(args, "repo"), Ref: text(args, "ref"), Query: raw(args, "query"), Path: text(args, "path"), Regex: regex, IgnoreCase: ignore, Max: max}) }), }, } } // text is an argument trimmed; raw is it as given. func text(args map[string]any, key string) string { return strings.TrimSpace(raw(args, key)) } func raw(args map[string]any, key string) string { s, _ := args[key].(string) return s } func count(args map[string]any, key string, fallback, least, most int) (int, error) { v, given := args[key] if !given || v == nil { return fallback, nil } x, ok := v.(float64) if !ok || x != math.Trunc(x) { return 0, refuse("%s must be a whole number", key) } if x < float64(least) || x > float64(most) { return 0, refuse("%s must be from %d to %d", key, least, most) } return int(x), nil } func flag(args map[string]any, key string) (bool, error) { v, given := args[key] if !given || v == nil { return false, nil } switch b := v.(type) { case bool: return b, nil case string: if b == "true" || b == "false" { return b == "true", nil } } return false, refuse("%s is true or false, not %v", key, v) }