Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1cb53295 |
@@ -13,12 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type createArgs struct {
|
type createArgs struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Hypothesis string `json:"hypothesis"`
|
Hypothesis string `json:"hypothesis"`
|
||||||
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,25 +77,32 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw
|
|||||||
}
|
}
|
||||||
res.Reached = append(res.Reached, stepCreateRepo)
|
res.Reached = append(res.Reached, stepCreateRepo)
|
||||||
|
|
||||||
// Step 2: create empty GitHub repo. Gitea's push-mirror cannot push
|
// Steps 2+3 are skipped when MirrorToGitHub is false. Default per
|
||||||
// to a non-existent remote, so the destination must exist before
|
// infra ADR (Gitea as true master, GitHub as optional opt-in): keep
|
||||||
// step 3 configures the mirror. Skipped when GitHub client is unset
|
// client / business-logic / personal repos Gitea-only. Set
|
||||||
// (degraded mode — see Config.GitHub doc).
|
// `mirror_to_github: true` for open-source projects that want a
|
||||||
if s.cfg.GitHub != nil {
|
// public GitHub mirror (hyperguild, gitea-mcp, template-*).
|
||||||
if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) {
|
if args.MirrorToGitHub {
|
||||||
return marshalPartial(res, stepCreateGitHub, err)
|
// 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
|
// Step 3: configure push mirror to GitHub. Idempotent: if a mirror with
|
||||||
// the same remote already exists, gitea-mcp returns Conflict; we swallow it.
|
// the same remote already exists, gitea-mcp returns Conflict; we swallow it.
|
||||||
if err := s.callMirror(ctx, args.Name); err != nil {
|
if err := s.callMirror(ctx, args.Name); err != nil {
|
||||||
if !isConflict(err) {
|
if !isConflict(err) {
|
||||||
return marshalPartial(res, stepMirror, 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
|
// 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")
|
||||||
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")
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user