Files
hyperguild/internal/skills/project/handlers_test.go
Mathias 6f1cb53295
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
feat(project_create): mirror_to_github opt-in, default false (infra#34 ADR)
Per the Gitea-as-true-master ADR (infra#34), GitHub mirror is now an
explicit opt-in via mirror_to_github=true. Default (omit / false) provisions
a Gitea repo + staging namespace + experiment-brief issue only — no GitHub
repo, no push-mirror.

Rationale: US cloud providers (Microsoft/GitHub) are subject to CLOUD Act
and NSL. Client code, business logic, and infra-adjacent repos should
never live on US-owned infrastructure. Only open-source projects intended
for public community (hyperguild, gitea-mcp, template-*) should opt in.

Changes
- internal/skills/project/handlers.go
  - createArgs gains MirrorToGitHub bool (json:"mirror_to_github,omitempty").
  - res.GitHubURL is set only when MirrorToGitHub is true; empty string otherwise.
  - Steps 2 (create_github_repo) + 3 (mirror) are wrapped in `if args.MirrorToGitHub`.
  - experimentBrief renders "Gitea-only" line by default and the existing
    "Push-mirror configured" line only on opt-in.
- internal/skills/project/skill.go
  - Tool schema gains mirror_to_github (boolean, default false) with description
    spelling out when to opt in. Tool Description updated to reflect new default.
- internal/skills/project/handlers_test.go
  - Added mirroredArgs() helper (happyArgs + mirror_to_github:true).
  - Tests that exercise the GitHub flow (HappyPath, GitHubExists_Idempotent,
    GitHubFails, NoGitHubClient_DegradedMode, Idempotent_RepoExists,
    MirrorFails, InfraCommitFails) switched to mirroredArgs.
  - Added TestProjectCreate_DefaultSkipsGitHubMirror covering the Gitea-only
    path: 3 gitea-mcp calls, zero GitHub calls, empty github_url, reached=
    [create_repo, infra_commit, issue], body reflects Gitea-only.

Closes gitea/mathias/hyperguild#17. Moves infra#34 acceptance item
"project_create updated: mirror_to_github defaults to false".
2026-05-20 08:35:02 +02:00

420 lines
14 KiB
Go

package project_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/mathiasbq/supervisor/internal/mcpclient"
"github.com/mathiasbq/supervisor/internal/skills/project"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeGitHub captures POST /user/repos calls.
type fakeGitHub struct {
mu sync.Mutex
Calls []map[string]any
ReturnError int // 0 = 201 Created, 422 = already exists, etc.
}
func (g *fakeGitHub) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var args map[string]any
_ = json.NewDecoder(r.Body).Decode(&args)
g.mu.Lock()
g.Calls = append(g.Calls, args)
code := g.ReturnError
g.mu.Unlock()
switch code {
case 0:
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"full_name":"mathiasb/x","html_url":"https://github.com/mathiasb/x","clone_url":"https://github.com/mathiasb/x.git"}`))
case 422:
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"errors":[{"message":"name already exists on this account"}]}`))
default:
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"message":"boom"}`))
}
})
}
// fakeGiteaMCP implements just enough of the JSON-RPC tools/call surface
// to drive project_create end-to-end without an actual gitea-mcp server.
type fakeGiteaMCP struct {
mu sync.Mutex
// Recorded calls in order.
Calls []recordedCall
// Per-tool response. Default is a generic success object.
Responses map[string]any
// Per-tool error response, takes precedence over Responses.
Errors map[string]rpcErr
}
type rpcErr struct {
Code int
Message string
}
type recordedCall struct {
Tool string
Args map[string]any
}
func (f *fakeGiteaMCP) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
Params json.RawMessage `json:"params"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
_ = json.Unmarshal(req.Params, &p)
var args map[string]any
_ = json.Unmarshal(p.Arguments, &args)
f.mu.Lock()
f.Calls = append(f.Calls, recordedCall{Tool: p.Name, Args: args})
errResp, hasErr := f.Errors[p.Name]
var resp any
if r, ok := f.Responses[p.Name]; ok {
resp = r
} else {
resp = map[string]any{"html_url": "http://gitea.example/" + p.Name}
}
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if hasErr {
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"error": map[string]any{"code": errResp.Code, "message": errResp.Message},
})
_, _ = w.Write(body)
return
}
respText, _ := json.Marshal(resp)
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"content": []map[string]any{{"type": "text", "text": string(respText)}},
},
})
_, _ = w.Write(body)
})
}
func newSkill(t *testing.T, f *fakeGiteaMCP) (*project.Skill, *fakeGitHub) {
t.Helper()
srv := httptest.NewServer(f.handler())
t.Cleanup(srv.Close)
gh := &fakeGitHub{}
ghSrv := httptest.NewServer(gh.handler())
t.Cleanup(ghSrv.Close)
return project.New(project.Config{
Client: mustClient(t, srv.URL),
GitHub: githubclient.New("ghp_test").WithBaseURL(ghSrv.URL),
GiteaOwner: "mathias",
GitHubOwner: "mathiasb",
GitHubPAT: "ghp_test",
InfraRepo: "infra",
}), gh
}
// newSkillNoGitHub builds a skill with the GitHub client unset — degraded
// mode where the github-repo-creation step is skipped.
func newSkillNoGitHub(t *testing.T, f *fakeGiteaMCP) *project.Skill {
t.Helper()
srv := httptest.NewServer(f.handler())
t.Cleanup(srv.Close)
return project.New(project.Config{
Client: mustClient(t, srv.URL),
GiteaOwner: "mathias",
GitHubOwner: "mathiasb",
InfraRepo: "infra",
})
}
// mustClient builds an mcpclient against an httptest server. Uses a
// non-empty dummy token because httptest servers don't enforce bearer
// auth, but mcpclient.New now requires non-empty token (see #13).
func mustClient(t *testing.T, url string) *mcpclient.Client {
t.Helper()
c, err := mcpclient.New(url, "test-token")
require.NoError(t, err)
return c
}
// happyArgs returns the minimal valid request. With the Gitea-as-true-master
// ADR shipped, this defaults to Gitea-only (mirror_to_github omitted = false).
// Tests that need the full Gitea + GitHub mirror flow use mirroredArgs().
func happyArgs() json.RawMessage {
return json.RawMessage(`{
"name":"my-experiment",
"description":"One-line desc",
"hypothesis":"We believe X produces Y",
"folder":"AGENTS",
"stack":"go-agent",
"private":true
}`)
}
// mirroredArgs is happyArgs + mirror_to_github=true — the explicit opt-in
// path. Equivalent to the pre-ADR default.
func mirroredArgs() json.RawMessage {
return json.RawMessage(`{
"name":"my-experiment",
"description":"One-line desc",
"hypothesis":"We believe X produces Y",
"folder":"AGENTS",
"stack":"go-agent",
"private":true,
"mirror_to_github":true
}`)
}
func TestProjectCreate_HappyPath(t *testing.T) {
f := &fakeGiteaMCP{
Responses: map[string]any{
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill, gh := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.NoError(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
assert.Equal(t, "https://github.com/mathiasb/my-experiment", res["github_url"])
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
assert.Contains(t, res["next_steps"], "cd ~/dev/AGENTS/my-experiment")
assert.Contains(t, res["next_steps"], "git remote add origin")
// All 4 gitea-mcp calls in order.
require.Len(t, f.Calls, 4)
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool)
assert.Equal(t, "file_write_branch", f.Calls[2].Tool)
assert.Equal(t, "issue_create", f.Calls[3].Tool)
// GitHub repo created between create_project_from_template and mirror.
require.Len(t, gh.Calls, 1)
assert.Equal(t, "my-experiment", gh.Calls[0]["name"])
assert.Equal(t, true, gh.Calls[0]["private"])
assert.Equal(t, false, gh.Calls[0]["auto_init"])
// template selection wired from stack
assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"])
// mirror config
assert.Equal(t, "add", f.Calls[1].Args["action"])
assert.Equal(t, "https://github.com/mathiasb/my-experiment.git", f.Calls[1].Args["remote_address"])
assert.Equal(t, "ghp_test", f.Calls[1].Args["remote_password"])
// infra commit path
assert.Equal(t, "k3s/staging/my-experiment/namespace.yaml", f.Calls[2].Args["path"])
assert.Contains(t, f.Calls[2].Args["content"], "name: staging-my-experiment")
assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild")
// PAT must NOT appear in the response
assert.NotContains(t, string(out), "ghp_test")
// reached records the github step too.
reached := res["reached"].([]any)
assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror", "infra_commit", "issue"}, reached)
}
func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) {
f := &fakeGiteaMCP{
Responses: map[string]any{
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill, gh := newSkill(t, f)
gh.ReturnError = 422 // already exists
_, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.NoError(t, err, "422 already-exists should be idempotent")
require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422")
}
func TestProjectCreate_GitHubFails(t *testing.T) {
f := &fakeGiteaMCP{}
skill, gh := newSkill(t, f)
gh.ReturnError = 401 // bad PAT
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.Error(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "create_github_repo", res["failed_step"])
assert.Equal(t, []any{"create_repo"}, res["reached"])
require.Len(t, f.Calls, 1, "mirror + later steps must not run when github creation fails")
}
func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) {
f := &fakeGiteaMCP{
Responses: map[string]any{
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill := newSkillNoGitHub(t, f)
// Use mirroredArgs so we exercise the GitHub-mirror path. With the
// GitHub client nil, the create_github_repo step is skipped but the
// mirror step still attempts to configure the push-mirror remote
// (degraded mode preserves the prior contract for opted-in projects).
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.NoError(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
// reached does NOT include create_github_repo when client is nil.
reached := res["reached"].([]any)
assert.Equal(t, []any{"create_repo", "mirror", "infra_commit", "issue"}, reached)
}
func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
f := &fakeGiteaMCP{
Errors: map[string]rpcErr{
"create_project_from_template": {Code: -32003, Message: "already exists"},
},
Responses: map[string]any{
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill, _ := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.NoError(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
// Still ran all 4 gitea-mcp steps; idempotent flow falls through.
require.Len(t, f.Calls, 4)
}
func TestProjectCreate_MirrorFails(t *testing.T) {
f := &fakeGiteaMCP{
Errors: map[string]rpcErr{
"repo_mirror_push": {Code: -32000, Message: "github unreachable"},
},
}
skill, _ := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.Error(t, err)
assert.Contains(t, err.Error(), `"mirror" failed`)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "mirror", res["failed_step"])
reached := res["reached"].([]any)
assert.Equal(t, []any{"create_repo", "create_github_repo"}, reached)
// Steps 1 (create) + 2 (mirror attempt) reached gitea; github made 1 call.
require.Len(t, f.Calls, 2)
}
func TestProjectCreate_InfraCommitFails(t *testing.T) {
f := &fakeGiteaMCP{
Errors: map[string]rpcErr{
"file_write_branch": {Code: -32000, Message: "write rejected"},
},
}
skill, _ := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
require.Error(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "infra_commit", res["failed_step"])
reached := res["reached"].([]any)
assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror"}, reached)
require.Len(t, f.Calls, 3)
}
func TestProjectCreate_ValidationErrors(t *testing.T) {
f := &fakeGiteaMCP{}
skill, _ := newSkill(t, f)
cases := []struct {
name string
body string
want string
}{
{"missing name", `{"description":"d","hypothesis":"h","stack":"go-agent"}`, "name"},
{"missing description", `{"name":"x","hypothesis":"h","stack":"go-agent"}`, "description"},
{"missing hypothesis", `{"name":"x","description":"d","stack":"go-agent"}`, "hypothesis"},
{"bad stack", `{"name":"x","description":"d","hypothesis":"h","stack":"python"}`, "stack"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := skill.Handle(context.Background(), "project_create", json.RawMessage(tc.body))
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), tc.want), "want %q in %v", tc.want, err)
})
}
assert.Empty(t, f.Calls, "no upstream calls should occur on validation failure")
}
func TestProjectCreate_DefaultSkipsGitHubMirror(t *testing.T) {
// Default (mirror_to_github omitted) skips create_github_repo + mirror
// per the Gitea-as-true-master ADR. Gitea repo + staging namespace
// + issue still run; github_url is empty in the response.
f := &fakeGiteaMCP{
Responses: map[string]any{
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill, gh := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.NoError(t, err)
var res map[string]any
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
assert.Equal(t, "", res["github_url"], "github_url must be empty when mirror not opted in")
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
// 3 gitea-mcp calls: template create, staging file write, issue. NO mirror call.
require.Len(t, f.Calls, 3)
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
assert.Equal(t, "file_write_branch", f.Calls[1].Tool)
assert.Equal(t, "issue_create", f.Calls[2].Tool)
// Zero GitHub API calls.
assert.Empty(t, gh.Calls, "no GitHub repo created when mirror_to_github is false")
// reached lists the Gitea-only path.
reached := res["reached"].([]any)
assert.Equal(t, []any{"create_repo", "infra_commit", "issue"}, reached)
// experiment-brief body reflects Gitea-only provisioning.
require.Contains(t, f.Calls[2].Args["body"], "Gitea-only")
require.NotContains(t, f.Calls[2].Args["body"], "Push-mirror configured")
}
func TestProjectCreate_UnknownTool(t *testing.T) {
f := &fakeGiteaMCP{}
skill, _ := newSkill(t, f)
_, err := skill.Handle(context.Background(), "nope", happyArgs())
require.Error(t, err)
}