mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
An agent could not get a finished change onto the forge without someone pushing by hand, so the branch / pull request / approval rule could not be followed. A Go bundle serves gitea_commit, held to gitea_api's rule (never the default branch, never a protected one), and the summarised reads agents otherwise made through gitea_api as raw JSON.
237 lines
10 KiB
Go
237 lines
10 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}, created or updated as " +
|
|
"the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " +
|
|
"Answers the commit it made and its parent, read back from the branch. 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)
|
|
}
|