1 Commits

Author SHA1 Message Date
Mathias
6f1cb53295 feat(project_create): mirror_to_github opt-in, default false (infra#34 ADR)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
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
3 changed files with 116 additions and 34 deletions

View File

@@ -19,6 +19,7 @@ type createArgs struct {
Folder string `json:"folder"` Folder string `json:"folder"`
Stack string `json:"stack"` Stack string `json:"stack"`
Private bool `json:"private"` Private bool `json:"private"`
MirrorToGitHub bool `json:"mirror_to_github,omitempty"`
} }
type createResult struct { type createResult struct {
@@ -59,11 +60,12 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw
tmpl := templateFor(args.Stack) tmpl := templateFor(args.Stack)
giteaURL := fmt.Sprintf("http://gitea.d-ma.be/%s/%s", s.cfg.GiteaOwner, args.Name) 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{ res := createResult{
GiteaURL: giteaURL, GiteaURL: giteaURL,
GitHubURL: githubURL, }
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, // Step 1: create_project_from_template. If the repo already exists,
@@ -75,6 +77,12 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw
} }
res.Reached = append(res.Reached, stepCreateRepo) res.Reached = append(res.Reached, stepCreateRepo)
// 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 // Step 2: create empty GitHub repo. Gitea's push-mirror cannot push
// to a non-existent remote, so the destination must exist before // to a non-existent remote, so the destination must exist before
// step 3 configures the mirror. Skipped when GitHub client is unset // step 3 configures the mirror. Skipped when GitHub client is unset
@@ -94,6 +102,7 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw
} }
} }
res.Reached = append(res.Reached, stepMirror) res.Reached = append(res.Reached, stepMirror)
}
// Step 3: commit staging namespace manifest to infra repo. Done before // 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. // 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("- Repo created from `template-")
b.WriteString(args.Stack) b.WriteString(args.Stack)
b.WriteString("` on Gitea.\n") b.WriteString("` on Gitea.\n")
if args.MirrorToGitHub {
b.WriteString("- Push-mirror configured to GitHub.\n") 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") b.WriteString("- Staging namespace manifest committed to infra repo.\n\n")
if existed { if existed {
b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n") b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n")

View File

@@ -158,6 +158,9 @@ func mustClient(t *testing.T, url string) *mcpclient.Client {
return c 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 { func happyArgs() json.RawMessage {
return json.RawMessage(`{ return json.RawMessage(`{
"name":"my-experiment", "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) { func TestProjectCreate_HappyPath(t *testing.T) {
f := &fakeGiteaMCP{ f := &fakeGiteaMCP{
Responses: map[string]any{ Responses: map[string]any{
@@ -177,7 +194,7 @@ func TestProjectCreate_HappyPath(t *testing.T) {
} }
skill, gh := newSkill(t, f) 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) require.NoError(t, err)
var res map[string]any var res map[string]any
@@ -228,7 +245,7 @@ func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) {
skill, gh := newSkill(t, f) skill, gh := newSkill(t, f)
gh.ReturnError = 422 // already exists 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.NoError(t, err, "422 already-exists should be idempotent")
require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422") 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) skill, gh := newSkill(t, f)
gh.ReturnError = 401 // bad PAT 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) require.Error(t, err)
var res map[string]any var res map[string]any
require.NoError(t, json.Unmarshal(out, &res)) require.NoError(t, json.Unmarshal(out, &res))
@@ -255,7 +272,11 @@ func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) {
} }
skill := newSkillNoGitHub(t, f) 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) require.NoError(t, err)
var res map[string]any var res map[string]any
require.NoError(t, json.Unmarshal(out, &res)) require.NoError(t, json.Unmarshal(out, &res))
@@ -275,7 +296,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
} }
skill, _ := newSkill(t, f) 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) require.NoError(t, err)
var res map[string]any var res map[string]any
@@ -295,7 +316,7 @@ func TestProjectCreate_MirrorFails(t *testing.T) {
} }
skill, _ := newSkill(t, f) 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) require.Error(t, err)
assert.Contains(t, err.Error(), `"mirror" failed`) assert.Contains(t, err.Error(), `"mirror" failed`)
@@ -317,7 +338,7 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) {
} }
skill, _ := newSkill(t, f) 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) require.Error(t, err)
var res map[string]any 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") 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) { func TestProjectCreate_UnknownTool(t *testing.T) {
f := &fakeGiteaMCP{} f := &fakeGiteaMCP{}
skill, _ := newSkill(t, f) skill, _ := newSkill(t, f)

View File

@@ -79,13 +79,22 @@ func (s *Skill) Tools() []registry.ToolDef {
"description": "Selects template-go-agent or template-go-web.", "description": "Selects template-go-agent or template-go-web.",
}, },
"private": map[string]any{"type": "boolean"}, "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"}, "required": []string{"name", "description", "hypothesis", "stack"},
}) })
return []registry.ToolDef{ return []registry.ToolDef{
{ {
Name: "project_create", 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.", 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, InputSchema: schema,
}, },
} }