diff --git a/internal/skills/project/handlers.go b/internal/skills/project/handlers.go index 162d1f0..a5d41e5 100644 --- a/internal/skills/project/handlers.go +++ b/internal/skills/project/handlers.go @@ -13,12 +13,13 @@ import ( ) type createArgs struct { - Name string `json:"name"` - Description string `json:"description"` - Hypothesis string `json:"hypothesis"` - Folder string `json:"folder"` - Stack string `json:"stack"` - Private bool `json:"private"` + Name string `json:"name"` + Description string `json:"description"` + Hypothesis string `json:"hypothesis"` + Folder string `json:"folder"` + Stack string `json:"stack"` + Private bool `json:"private"` + MirrorToGitHub bool `json:"mirror_to_github,omitempty"` } type createResult struct { @@ -59,11 +60,12 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw tmpl := templateFor(args.Stack) giteaURL := fmt.Sprintf("http://gitea.d-ma.be/%s/%s", s.cfg.GiteaOwner, args.Name) - githubURL := fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name) res := createResult{ - GiteaURL: giteaURL, - GitHubURL: githubURL, + GiteaURL: giteaURL, + } + if args.MirrorToGitHub { + res.GitHubURL = fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name) } // Step 1: create_project_from_template. If the repo already exists, @@ -75,25 +77,32 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw } res.Reached = append(res.Reached, stepCreateRepo) - // Step 2: create empty GitHub repo. Gitea's push-mirror cannot push - // to a non-existent remote, so the destination must exist before - // step 3 configures the mirror. Skipped when GitHub client is unset - // (degraded mode — see Config.GitHub doc). - if s.cfg.GitHub != nil { - if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) { - return marshalPartial(res, stepCreateGitHub, err) + // Steps 2+3 are skipped when MirrorToGitHub is false. Default per + // infra ADR (Gitea as true master, GitHub as optional opt-in): keep + // client / business-logic / personal repos Gitea-only. Set + // `mirror_to_github: true` for open-source projects that want a + // public GitHub mirror (hyperguild, gitea-mcp, template-*). + if args.MirrorToGitHub { + // Step 2: create empty GitHub repo. Gitea's push-mirror cannot push + // to a non-existent remote, so the destination must exist before + // step 3 configures the mirror. Skipped when GitHub client is unset + // (degraded mode — see Config.GitHub doc). + if s.cfg.GitHub != nil { + if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) { + return marshalPartial(res, stepCreateGitHub, err) + } + res.Reached = append(res.Reached, stepCreateGitHub) } - res.Reached = append(res.Reached, stepCreateGitHub) - } - // Step 3: configure push mirror to GitHub. Idempotent: if a mirror with - // the same remote already exists, gitea-mcp returns Conflict; we swallow it. - if err := s.callMirror(ctx, args.Name); err != nil { - if !isConflict(err) { - return marshalPartial(res, stepMirror, err) + // Step 3: configure push mirror to GitHub. Idempotent: if a mirror with + // the same remote already exists, gitea-mcp returns Conflict; we swallow it. + if err := s.callMirror(ctx, args.Name); err != nil { + if !isConflict(err) { + return marshalPartial(res, stepMirror, err) + } } + res.Reached = append(res.Reached, stepMirror) } - res.Reached = append(res.Reached, stepMirror) // Step 3: commit staging namespace manifest to infra repo. Done before // the issue so the staging env is reconciling by the time the issue lands. @@ -228,7 +237,11 @@ func experimentBrief(args createArgs, existed bool) string { b.WriteString("- Repo created from `template-") b.WriteString(args.Stack) b.WriteString("` on Gitea.\n") - b.WriteString("- Push-mirror configured to GitHub.\n") + if args.MirrorToGitHub { + b.WriteString("- Push-mirror configured to GitHub.\n") + } else { + b.WriteString("- Gitea-only (no GitHub mirror — set `mirror_to_github: true` to opt in).\n") + } b.WriteString("- Staging namespace manifest committed to infra repo.\n\n") if existed { b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n") diff --git a/internal/skills/project/handlers_test.go b/internal/skills/project/handlers_test.go index 1999e5b..fa934e8 100644 --- a/internal/skills/project/handlers_test.go +++ b/internal/skills/project/handlers_test.go @@ -158,6 +158,9 @@ func mustClient(t *testing.T, url string) *mcpclient.Client { 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", @@ -169,6 +172,20 @@ func happyArgs() json.RawMessage { }`) } +// 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{ @@ -177,7 +194,7 @@ func TestProjectCreate_HappyPath(t *testing.T) { } skill, gh := newSkill(t, f) - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) require.NoError(t, err) var res map[string]any @@ -228,7 +245,7 @@ func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) { skill, gh := newSkill(t, f) gh.ReturnError = 422 // already exists - _, err := skill.Handle(context.Background(), "project_create", happyArgs()) + _, 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") } @@ -238,7 +255,7 @@ func TestProjectCreate_GitHubFails(t *testing.T) { skill, gh := newSkill(t, f) gh.ReturnError = 401 // bad PAT - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + 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)) @@ -255,7 +272,11 @@ func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) { } skill := newSkillNoGitHub(t, f) - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + // 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)) @@ -275,7 +296,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) { } skill, _ := newSkill(t, f) - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) require.NoError(t, err) var res map[string]any @@ -295,7 +316,7 @@ func TestProjectCreate_MirrorFails(t *testing.T) { } skill, _ := newSkill(t, f) - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) require.Error(t, err) assert.Contains(t, err.Error(), `"mirror" failed`) @@ -317,7 +338,7 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) { } skill, _ := newSkill(t, f) - out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + out, err := skill.Handle(context.Background(), "project_create", mirroredArgs()) require.Error(t, err) var res map[string]any @@ -351,6 +372,45 @@ func TestProjectCreate_ValidationErrors(t *testing.T) { 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) diff --git a/internal/skills/project/skill.go b/internal/skills/project/skill.go index 9793a7a..e43b186 100644 --- a/internal/skills/project/skill.go +++ b/internal/skills/project/skill.go @@ -79,13 +79,22 @@ func (s *Skill) Tools() []registry.ToolDef { "description": "Selects template-go-agent or template-go-web.", }, "private": map[string]any{"type": "boolean"}, + "mirror_to_github": map[string]any{ + "type": "boolean", + "description": "Default false. When true, also create an empty GitHub repo " + + "and configure a push-mirror from Gitea. Opt-in per the Gitea-as-true-master " + + "ADR — only set true for open-source projects (hyperguild, gitea-mcp, template-*). " + + "Never set true for client projects, business logic, or personal experiments.", + }, }, "required": []string{"name", "description", "hypothesis", "stack"}, }) return []registry.ToolDef{ { - Name: "project_create", - Description: "Bootstrap a new project: Gitea repo from template, GitHub push-mirror, staging namespace manifest, experiment-brief issue. Idempotent — re-running with an existing repo returns the existing URLs.", + Name: "project_create", + Description: "Bootstrap a new project: Gitea repo from template, staging namespace manifest, " + + "experiment-brief issue. Optionally mirrors to GitHub when `mirror_to_github: true` " + + "(default false). Idempotent — re-running with an existing repo returns the existing URLs.", InputSchema: schema, }, }