feat(routing): create GitHub destination repo before configuring push-mirror
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped

Gitea's push-mirror cannot push to a non-existent remote — it just
runs 'git push' against whatever URL it's given. So a project_create
flow that only configures the mirror leaves the GitHub side as an
unfulfillable URL.

New internal/githubclient package: single-purpose client that POSTs
/user/repos to create an empty private repo (auto_init=false so the
first mirror push doesn't conflict with a generated README). Treats
422 'name already exists' as idempotent success via ErrAlreadyExists.
401/403 are surfaced as 'PAT missing repo scope or invalid' so the
operator sees the real cause instead of a vague upstream error.

Skill wiring:
- New stepCreateGitHub between stepCreateRepo and stepMirror in the
  orchestrator.
- Skipped entirely when Config.GitHub is nil (degraded mode — the
  routing pod runs without GITHUB_PAT, mirror config still lands,
  but the actual sync to github fails until the repo exists).
- cmd/routing/main.go constructs githubclient.New(GitHubPAT) only
  when the PAT is set; the skill receives nil otherwise.

Tests:
- happy path: fake github 201 + assertions that the 'reached' array
  is [create_repo, create_github_repo, mirror, infra_commit, issue].
- github 422 already-exists: idempotent, all gitea steps still run.
- github 401: returns failed_step=create_github_repo, no mirror or
  later steps.
- degraded mode (Config.GitHub nil): reached omits create_github_repo,
  rest of the flow runs unchanged.

Updated existing tests to read [skill, gh] from newSkill instead of
just skill, and adjusted reached-array expectations to include the
new step.

Tracks #10.
This commit is contained in:
Mathias
2026-05-18 13:42:03 +02:00
parent d1c8e3396f
commit a220fcaf2b
6 changed files with 343 additions and 22 deletions

View File

@@ -9,12 +9,42 @@ import (
"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 {
@@ -85,7 +115,28 @@ func (f *fakeGiteaMCP) handler() http.Handler {
})
}
func newSkill(t *testing.T, f *fakeGiteaMCP) *project.Skill {
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: mcpclient.New(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)
@@ -93,7 +144,6 @@ func newSkill(t *testing.T, f *fakeGiteaMCP) *project.Skill {
Client: mcpclient.New(srv.URL, ""),
GiteaOwner: "mathias",
GitHubOwner: "mathiasb",
GitHubPAT: "ghp_test",
InfraRepo: "infra",
})
}
@@ -115,7 +165,7 @@ func TestProjectCreate_HappyPath(t *testing.T) {
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill := newSkill(t, f)
skill, gh := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.NoError(t, err)
@@ -128,13 +178,19 @@ func TestProjectCreate_HappyPath(t *testing.T) {
assert.Contains(t, res["next_steps"], "cd ~/dev/AGENTS/my-experiment")
assert.Contains(t, res["next_steps"], "git remote add origin")
// All four steps in order.
// 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
@@ -147,6 +203,55 @@ func TestProjectCreate_HappyPath(t *testing.T) {
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", happyArgs())
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", happyArgs())
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)
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))
// 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) {
@@ -158,7 +263,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
},
}
skill := newSkill(t, f)
skill, _ := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.NoError(t, err)
@@ -168,7 +273,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
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 steps; idempotent flow falls through the conflict.
// Still ran all 4 gitea-mcp steps; idempotent flow falls through.
require.Len(t, f.Calls, 4)
}
@@ -178,7 +283,7 @@ func TestProjectCreate_MirrorFails(t *testing.T) {
"repo_mirror_push": {Code: -32000, Message: "github unreachable"},
},
}
skill := newSkill(t, f)
skill, _ := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.Error(t, err)
@@ -188,9 +293,9 @@ func TestProjectCreate_MirrorFails(t *testing.T) {
require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "mirror", res["failed_step"])
reached := res["reached"].([]any)
assert.Equal(t, []any{"create_repo"}, reached)
assert.Equal(t, []any{"create_repo", "create_github_repo"}, reached)
// Only steps 1 + 2 actually called.
// Steps 1 (create) + 2 (mirror attempt) reached gitea; github made 1 call.
require.Len(t, f.Calls, 2)
}
@@ -200,7 +305,7 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) {
"file_write_branch": {Code: -32000, Message: "write rejected"},
},
}
skill := newSkill(t, f)
skill, _ := newSkill(t, f)
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.Error(t, err)
@@ -209,13 +314,13 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) {
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", "mirror"}, reached)
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)
skill, _ := newSkill(t, f)
cases := []struct {
name string
body string
@@ -238,7 +343,7 @@ func TestProjectCreate_ValidationErrors(t *testing.T) {
func TestProjectCreate_UnknownTool(t *testing.T) {
f := &fakeGiteaMCP{}
skill := newSkill(t, f)
skill, _ := newSkill(t, f)
_, err := skill.Handle(context.Background(), "nope", happyArgs())
require.Error(t, err)
}