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) }