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

@@ -17,6 +17,7 @@ import (
"github.com/mathiasbq/supervisor/internal/auth" "github.com/mathiasbq/supervisor/internal/auth"
"github.com/mathiasbq/supervisor/internal/config" "github.com/mathiasbq/supervisor/internal/config"
iexec "github.com/mathiasbq/supervisor/internal/exec" iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/mathiasbq/supervisor/internal/mcp" "github.com/mathiasbq/supervisor/internal/mcp"
"github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/mcpclient"
"github.com/mathiasbq/supervisor/internal/registry" "github.com/mathiasbq/supervisor/internal/registry"
@@ -102,8 +103,13 @@ func main() {
})) }))
if cfg.GiteaMCPURL != "" { if cfg.GiteaMCPURL != "" {
var ghClient *githubclient.Client
if cfg.GitHubPAT != "" {
ghClient = githubclient.New(cfg.GitHubPAT)
}
reg.Register(project.New(project.Config{ reg.Register(project.New(project.Config{
Client: mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken), Client: mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken),
GitHub: ghClient,
GiteaOwner: cfg.GiteaOwner, GiteaOwner: cfg.GiteaOwner,
GitHubOwner: cfg.GitHubOwner, GitHubOwner: cfg.GitHubOwner,
GitHubPAT: cfg.GitHubPAT, GitHubPAT: cfg.GitHubPAT,

View File

@@ -0,0 +1,108 @@
// Package githubclient is a minimal GitHub REST API client. The hyperguild
// project_create flow is gitea-first; this client exists only to create an
// empty repo on GitHub before the gitea→github push-mirror is configured,
// since the mirror cannot push to a non-existent remote.
package githubclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultAPI = "https://api.github.com"
type Client struct {
api string
token string
http *http.Client
}
// New returns a Client with the given personal access token (repo scope).
func New(token string) *Client {
return &Client{
api: defaultAPI,
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// WithBaseURL overrides the API base (test injection).
func (c *Client) WithBaseURL(u string) *Client {
c.api = u
return c
}
// Repo is the subset of GitHub's repo response we surface upstream.
type Repo struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
Private bool `json:"private"`
}
type createRepoArgs struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Private bool `json:"private"`
AutoInit bool `json:"auto_init"`
}
// ErrAlreadyExists is returned by CreateRepo when GitHub responds 422 with
// "name already exists". Callers treat it as idempotent success.
var ErrAlreadyExists = fmt.Errorf("github repo already exists")
// CreateRepo creates a repo under the authenticated user's account.
// auto_init is always false — the push-mirror will populate the repo from
// gitea, so an auto-generated README would conflict on first push.
func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*Repo, error) {
if c.token == "" {
return nil, fmt.Errorf("github pat not configured")
}
body, _ := json.Marshal(createRepoArgs{
Name: name,
Description: description,
Private: private,
AutoInit: false,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.api+"/user/repos", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
switch resp.StatusCode {
case http.StatusCreated:
var r Repo
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &r, nil
case http.StatusUnprocessableEntity:
// 422 covers "name already exists" + a handful of other validation
// errors. Treat any 422 that mentions "already exists" as idempotent
// success; everything else surfaces verbatim.
if bytes.Contains(raw, []byte("already exists")) {
return nil, ErrAlreadyExists
}
return nil, fmt.Errorf("github 422: %s", string(raw))
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("github auth %d: PAT missing repo scope or invalid", resp.StatusCode)
default:
return nil, fmt.Errorf("github %d: %s", resp.StatusCode, string(raw))
}
}

View File

@@ -0,0 +1,71 @@
package githubclient_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateRepo_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/user/repos", r.URL.Path)
assert.Equal(t, "token ghp_test", r.Header.Get("Authorization"))
var args map[string]any
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &args)
assert.Equal(t, "test-repo", args["name"])
assert.Equal(t, true, args["private"])
assert.Equal(t, false, args["auto_init"])
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"full_name":"mathiasb/test-repo","html_url":"https://github.com/mathiasb/test-repo","clone_url":"https://github.com/mathiasb/test-repo.git","private":true}`))
}))
defer srv.Close()
c := githubclient.New("ghp_test").WithBaseURL(srv.URL)
r, err := c.CreateRepo(context.Background(), "test-repo", "desc", true)
require.NoError(t, err)
assert.Equal(t, "mathiasb/test-repo", r.FullName)
assert.True(t, r.Private)
}
func TestCreateRepo_AlreadyExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}`))
}))
defer srv.Close()
c := githubclient.New("ghp_test").WithBaseURL(srv.URL)
_, err := c.CreateRepo(context.Background(), "x", "", false)
require.Error(t, err)
assert.True(t, errors.Is(err, githubclient.ErrAlreadyExists))
}
func TestCreateRepo_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := githubclient.New("ghp_test").WithBaseURL(srv.URL)
_, err := c.CreateRepo(context.Background(), "x", "", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "PAT missing repo scope")
}
func TestCreateRepo_NoToken(t *testing.T) {
c := githubclient.New("")
_, err := c.CreateRepo(context.Background(), "x", "", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "github pat not configured")
}

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/mcpclient"
) )
@@ -40,10 +41,11 @@ func errUnknownTool(name string) error { return fmt.Errorf("unknown tool: %s", n
// step names — must match what we surface in failed_step / reached. // step names — must match what we surface in failed_step / reached.
const ( const (
stepCreateRepo = "create_repo" stepCreateRepo = "create_repo"
stepMirror = "mirror" stepCreateGitHub = "create_github_repo"
stepInfraCommit = "infra_commit" stepMirror = "mirror"
stepIssue = "issue" stepInfraCommit = "infra_commit"
stepIssue = "issue"
) )
func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
@@ -73,7 +75,18 @@ 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: configure push mirror to GitHub. Idempotent: if a mirror with // 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)
}
// 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) {
@@ -135,6 +148,14 @@ func (s *Skill) callCreateRepo(ctx context.Context, args createArgs, template st
return false, err return false, err
} }
// callCreateGitHubRepo creates the empty destination repo on GitHub.
// auto_init=false in githubclient so first push from gitea doesn't conflict
// with an auto-generated README.
func (s *Skill) callCreateGitHubRepo(ctx context.Context, args createArgs) error {
_, err := s.cfg.GitHub.CreateRepo(ctx, args.Name, args.Description, args.Private)
return err
}
// callMirror configures the push mirror to GitHub. // callMirror configures the push mirror to GitHub.
func (s *Skill) callMirror(ctx context.Context, name string) error { func (s *Skill) callMirror(ctx context.Context, name string) error {
remote := fmt.Sprintf("https://github.com/%s/%s.git", s.cfg.GitHubOwner, name) remote := fmt.Sprintf("https://github.com/%s/%s.git", s.cfg.GitHubOwner, name)

View File

@@ -9,12 +9,42 @@ import (
"sync" "sync"
"testing" "testing"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/mcpclient"
"github.com/mathiasbq/supervisor/internal/skills/project" "github.com/mathiasbq/supervisor/internal/skills/project"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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 // fakeGiteaMCP implements just enough of the JSON-RPC tools/call surface
// to drive project_create end-to-end without an actual gitea-mcp server. // to drive project_create end-to-end without an actual gitea-mcp server.
type fakeGiteaMCP struct { 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() t.Helper()
srv := httptest.NewServer(f.handler()) srv := httptest.NewServer(f.handler())
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
@@ -93,7 +144,6 @@ func newSkill(t *testing.T, f *fakeGiteaMCP) *project.Skill {
Client: mcpclient.New(srv.URL, ""), Client: mcpclient.New(srv.URL, ""),
GiteaOwner: "mathias", GiteaOwner: "mathias",
GitHubOwner: "mathiasb", GitHubOwner: "mathiasb",
GitHubPAT: "ghp_test",
InfraRepo: "infra", 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"}, "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()) out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.NoError(t, err) 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"], "cd ~/dev/AGENTS/my-experiment")
assert.Contains(t, res["next_steps"], "git remote add origin") 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) require.Len(t, f.Calls, 4)
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool) assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool) assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool)
assert.Equal(t, "file_write_branch", f.Calls[2].Tool) assert.Equal(t, "file_write_branch", f.Calls[2].Tool)
assert.Equal(t, "issue_create", f.Calls[3].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 // template selection wired from stack
assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"]) assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"])
// mirror config // mirror config
@@ -147,6 +203,55 @@ func TestProjectCreate_HappyPath(t *testing.T) {
assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild") assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild")
// PAT must NOT appear in the response // PAT must NOT appear in the response
assert.NotContains(t, string(out), "ghp_test") 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) { 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"}, "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()) out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.NoError(t, err) 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", res["gitea_url"])
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_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) require.Len(t, f.Calls, 4)
} }
@@ -178,7 +283,7 @@ func TestProjectCreate_MirrorFails(t *testing.T) {
"repo_mirror_push": {Code: -32000, Message: "github unreachable"}, "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()) out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.Error(t, err) require.Error(t, err)
@@ -188,9 +293,9 @@ func TestProjectCreate_MirrorFails(t *testing.T) {
require.NoError(t, json.Unmarshal(out, &res)) require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "mirror", res["failed_step"]) assert.Equal(t, "mirror", res["failed_step"])
reached := res["reached"].([]any) 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) require.Len(t, f.Calls, 2)
} }
@@ -200,7 +305,7 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) {
"file_write_branch": {Code: -32000, Message: "write rejected"}, "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()) out, err := skill.Handle(context.Background(), "project_create", happyArgs())
require.Error(t, err) require.Error(t, err)
@@ -209,13 +314,13 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) {
require.NoError(t, json.Unmarshal(out, &res)) require.NoError(t, json.Unmarshal(out, &res))
assert.Equal(t, "infra_commit", res["failed_step"]) assert.Equal(t, "infra_commit", res["failed_step"])
reached := res["reached"].([]any) 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) require.Len(t, f.Calls, 3)
} }
func TestProjectCreate_ValidationErrors(t *testing.T) { func TestProjectCreate_ValidationErrors(t *testing.T) {
f := &fakeGiteaMCP{} f := &fakeGiteaMCP{}
skill := newSkill(t, f) skill, _ := newSkill(t, f)
cases := []struct { cases := []struct {
name string name string
body string body string
@@ -238,7 +343,7 @@ func TestProjectCreate_ValidationErrors(t *testing.T) {
func TestProjectCreate_UnknownTool(t *testing.T) { func TestProjectCreate_UnknownTool(t *testing.T) {
f := &fakeGiteaMCP{} f := &fakeGiteaMCP{}
skill := newSkill(t, f) skill, _ := newSkill(t, f)
_, err := skill.Handle(context.Background(), "nope", happyArgs()) _, err := skill.Handle(context.Background(), "nope", happyArgs())
require.Error(t, err) require.Error(t, err)
} }

View File

@@ -9,17 +9,26 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/mcpclient"
"github.com/mathiasbq/supervisor/internal/registry" "github.com/mathiasbq/supervisor/internal/registry"
) )
// Config holds the orchestration dependencies for the project skill. // Config holds the orchestration dependencies for the project skill.
type Config struct { type Config struct {
// Client talks to the gitea-mcp server. project_create makes 4 sequential // Client talks to the gitea-mcp server. project_create makes
// calls (create_project_from_template, repo_mirror_push, file_write_branch, // sequential calls (create_project_from_template, repo_mirror_push,
// issue_create) through this client. // file_write_branch, issue_create) through this client.
Client *mcpclient.Client Client *mcpclient.Client
// GitHub is the client used to create the empty destination repo on
// GitHub before the push-mirror is configured. Gitea's push-mirror
// cannot push to a non-existent remote, so this step is mandatory
// when GitHubPAT is set. Pass nil to skip github repo creation
// entirely (degraded mode — mirror config will land but the actual
// sync to github will fail until the repo exists).
GitHub *githubclient.Client
// GiteaOwner is the org/user that owns the new repo and the infra repo // GiteaOwner is the org/user that owns the new repo and the infra repo
// the namespace manifest is committed to (typically "mathias"). // the namespace manifest is committed to (typically "mathias").
GiteaOwner string GiteaOwner string
@@ -29,7 +38,8 @@ type Config struct {
GitHubOwner string GitHubOwner string
// GitHubPAT is the personal access token used as the push-mirror // GitHubPAT is the personal access token used as the push-mirror
// password. Must have `repo` scope. Never logged. // password and to create the destination repo on GitHub. Must have
// `repo` scope. Never logged.
GitHubPAT string GitHubPAT string
// InfraRepo is the name of the infra repo on Gitea where the // InfraRepo is the name of the infra repo on Gitea where the