mesh/merge-gate head dc3a177410fa merged into trunk c0880c4dc271 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head dc3a177410fa merged into trunk c0880c4dc271 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
254 lines
12 KiB
Go
254 lines
12 KiB
Go
// 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 | 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 " +
|
|
"it. A patch too large to type goes as patch_file with patch_sha256: the name of a file in the " +
|
|
"spool directory (read once, applied as patch is, removed afterwards, never read from anywhere " +
|
|
"else). 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"},
|
|
"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"},
|
|
},
|
|
},
|
|
"patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this, patch_file or files"),
|
|
"patch_file": str("for a patch too large to type: the NAME (32 to 64 characters of 0-9a-f, chosen at random by the caller, no path) of a file the caller wrote into the forge patch spool directory (the module's forge-patches directory); needs patch_sha256; give this, patch or files"),
|
|
"patch_sha256": str("with patch_file: the file's SHA-256, in hex; the commit is refused, and nothing is sent, when the file's differs"),
|
|
},
|
|
"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 commitAsk(ctx, f, os.Getenv(patchDirEnv), 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)
|
|
}
|