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.
101 lines
3.6 KiB
Go
101 lines
3.6 KiB
Go
// Package project implements the `project_create` MCP tool: a single-call
|
|
// pipeline that creates a Gitea repo from a template, configures push-mirror
|
|
// to GitHub, commits a staging namespace manifest to the infra repo, and
|
|
// opens an experiment-brief issue on the new repo. See hyperguild gitea
|
|
// issue #10 for the design.
|
|
package project
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
"github.com/mathiasbq/supervisor/internal/registry"
|
|
)
|
|
|
|
// Config holds the orchestration dependencies for the project skill.
|
|
type Config struct {
|
|
// Client talks to the gitea-mcp server. project_create makes
|
|
// sequential calls (create_project_from_template, repo_mirror_push,
|
|
// file_write_branch, issue_create) through this 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
|
|
// the namespace manifest is committed to (typically "mathias").
|
|
GiteaOwner string
|
|
|
|
// GitHubOwner is the GitHub org/user the push-mirror targets
|
|
// (typically "mathiasb").
|
|
GitHubOwner string
|
|
|
|
// GitHubPAT is the personal access token used as the push-mirror
|
|
// password and to create the destination repo on GitHub. Must have
|
|
// `repo` scope. Never logged.
|
|
GitHubPAT string
|
|
|
|
// InfraRepo is the name of the infra repo on Gitea where the
|
|
// k3s/staging/<name>/namespace.yaml manifest gets committed
|
|
// (typically "infra").
|
|
InfraRepo string
|
|
}
|
|
|
|
// Skill exposes project_create as an MCP tool.
|
|
type Skill struct{ cfg Config }
|
|
|
|
// New constructs the project Skill.
|
|
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
|
|
|
// Name returns the skill identifier.
|
|
func (s *Skill) Name() string { return "project" }
|
|
|
|
// Tools returns the MCP tool definitions for this skill.
|
|
func (s *Skill) Tools() []registry.ToolDef {
|
|
schema, _ := json.Marshal(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"name": map[string]any{
|
|
"type": "string",
|
|
"pattern": `^[a-z][a-z0-9-]{1,38}[a-z0-9]$`,
|
|
"description": "Lowercase repo name. 3-40 chars, must start with a letter.",
|
|
},
|
|
"description": map[string]any{"type": "string"},
|
|
"hypothesis": map[string]any{"type": "string"},
|
|
"folder": map[string]any{
|
|
"type": "string",
|
|
"description": "Informational only — appears in next_steps. Example: AGENTS, AI, QKX.",
|
|
},
|
|
"stack": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"go-agent", "go-web"},
|
|
"description": "Selects template-go-agent or template-go-web.",
|
|
},
|
|
"private": map[string]any{"type": "boolean"},
|
|
},
|
|
"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.",
|
|
InputSchema: schema,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Handle dispatches the tool call.
|
|
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
if tool != "project_create" {
|
|
return nil, errUnknownTool(tool)
|
|
}
|
|
return s.handleCreate(ctx, args)
|
|
}
|