package project import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/mathiasbq/supervisor/internal/githubclient" "github.com/mathiasbq/supervisor/internal/mcpclient" ) type createArgs struct { Name string `json:"name"` Description string `json:"description"` Hypothesis string `json:"hypothesis"` Folder string `json:"folder"` Stack string `json:"stack"` Private bool `json:"private"` } type createResult struct { GiteaURL string `json:"gitea_url"` GitHubURL string `json:"github_url"` IssueURL string `json:"issue_url"` NextSteps string `json:"next_steps"` // Reached records the steps that completed. Populated on partial failure // so callers can resume manually instead of guessing what already ran. Reached []string `json:"reached,omitempty"` // FailedStep is non-empty when a downstream gitea-mcp call returned an // error; the error itself is surfaced via the JSON-RPC error response, // this field tells the operator which step it happened in. FailedStep string `json:"failed_step,omitempty"` } func errUnknownTool(name string) error { return fmt.Errorf("unknown tool: %s", name) } // step names — must match what we surface in failed_step / reached. const ( stepCreateRepo = "create_repo" stepCreateGitHub = "create_github_repo" stepMirror = "mirror" stepInfraCommit = "infra_commit" stepIssue = "issue" ) func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { var args createArgs if err := json.Unmarshal(raw, &args); err != nil { return nil, fmt.Errorf("parse args: %w", err) } if err := validate(args); err != nil { return nil, err } tmpl := templateFor(args.Stack) 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{ GiteaURL: giteaURL, GitHubURL: githubURL, } // Step 1: create_project_from_template. If the repo already exists, // gitea-mcp returns -32003 Conflict; we treat that as idempotent success // and continue to the next steps so re-running self-heals partial runs. existed, err := s.callCreateRepo(ctx, args, tmpl) if err != nil { return marshalPartial(res, stepCreateRepo, err) } res.Reached = append(res.Reached, stepCreateRepo) // 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. if err := s.callMirror(ctx, args.Name); err != nil { if !isConflict(err) { return marshalPartial(res, stepMirror, err) } } res.Reached = append(res.Reached, stepMirror) // 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. branch := fmt.Sprintf("staging/%s", args.Name) if err := s.callInfraCommit(ctx, args.Name, branch); err != nil { if !isConflict(err) { return marshalPartial(res, stepInfraCommit, err) } } res.Reached = append(res.Reached, stepInfraCommit) // Step 4: open the experiment-brief issue on the new repo. issueURL, err := s.callIssue(ctx, args, existed) if err != nil { return marshalPartial(res, stepIssue, err) } res.IssueURL = issueURL res.Reached = append(res.Reached, stepIssue) folder := args.Folder if folder == "" { folder = "." } res.NextSteps = fmt.Sprintf( "cd ~/dev/%s/%s && task new-project -- %s personal %s %s && git remote add origin http://gitea.d-ma.be/%s/%s.git && git push -u origin main", folder, args.Name, args.Name, folder, args.Stack, s.cfg.GiteaOwner, args.Name, ) return marshalResult(res) } // callCreateRepo invokes create_project_from_template. Returns (existed, err) // where existed=true means the destination was already present and we should // treat it as a no-op success (idempotency). func (s *Skill) callCreateRepo(ctx context.Context, args createArgs, template string) (bool, error) { var out struct { HTMLURL string `json:"html_url"` } err := s.cfg.Client.CallTool(ctx, "create_project_from_template", map[string]any{ "owner": s.cfg.GiteaOwner, "name": args.Name, "description": args.Description, "private": args.Private, "template_name": template, }, &out) if err == nil { return false, nil } if isConflict(err) { return true, nil } 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. func (s *Skill) callMirror(ctx context.Context, name string) error { remote := fmt.Sprintf("https://github.com/%s/%s.git", s.cfg.GitHubOwner, name) return s.cfg.Client.CallTool(ctx, "repo_mirror_push", map[string]any{ "owner": s.cfg.GiteaOwner, "name": name, "action": "add", "remote_address": remote, "remote_username": s.cfg.GitHubOwner, "remote_password": s.cfg.GitHubPAT, "interval": "8h0m0s", "sync_on_commit": true, }, nil) } // callInfraCommit writes the staging namespace manifest into the infra repo // on a dedicated branch. Flux picks it up after merge. func (s *Skill) callInfraCommit(ctx context.Context, name, branch string) error { manifest := stagingNamespaceManifest(name, time.Now().UTC().Format(time.RFC3339)) return s.cfg.Client.CallTool(ctx, "file_write_branch", map[string]any{ "owner": s.cfg.GiteaOwner, "name": s.cfg.InfraRepo, "path": fmt.Sprintf("k3s/staging/%s/namespace.yaml", name), "content": manifest, "branch": branch, "base": "main", "message": fmt.Sprintf("feat(staging): add namespace for %s\n\nGenerated by hyperguild project_create.", name), }, nil) } // callIssue opens the experiment-brief issue on the newly-created repo. // existed=true (repo pre-existed) still posts a new brief — repeated runs // can intentionally restate intent without colliding. func (s *Skill) callIssue(ctx context.Context, args createArgs, existed bool) (string, error) { body := experimentBrief(args, existed) var out struct { HTMLURL string `json:"html_url"` } err := s.cfg.Client.CallTool(ctx, "issue_create", map[string]any{ "owner": s.cfg.GiteaOwner, "name": args.Name, "title": "experiment brief: " + args.Description, "body": body, }, &out) if err != nil { return "", err } return out.HTMLURL, nil } func stagingNamespaceManifest(name, createdAt string) string { return fmt.Sprintf(`apiVersion: v1 kind: Namespace metadata: name: staging-%s labels: managed-by: hyperguild project: %s created-at: "%s" `, name, name, createdAt) } func experimentBrief(args createArgs, existed bool) string { var b strings.Builder b.WriteString("## Hypothesis\n\n") b.WriteString(args.Hypothesis) b.WriteString("\n\n## Description\n\n") b.WriteString(args.Description) b.WriteString("\n\n## Stack\n\n`") b.WriteString(args.Stack) b.WriteString("`\n\n## Provisioning\n\n") b.WriteString("- Repo created from `template-") b.WriteString(args.Stack) b.WriteString("` on Gitea.\n") b.WriteString("- Push-mirror configured to GitHub.\n") b.WriteString("- Staging namespace manifest committed to infra repo.\n\n") if existed { b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n") } return b.String() } func validate(args createArgs) error { if args.Name == "" { return errors.New("name is required") } if args.Description == "" { return errors.New("description is required") } if args.Hypothesis == "" { return errors.New("hypothesis is required") } if args.Stack != "go-agent" && args.Stack != "go-web" { return fmt.Errorf("stack must be go-agent or go-web, got %q", args.Stack) } return nil } func templateFor(stack string) string { switch stack { case "go-agent": return "template-go-agent" default: return "template-go-web" } } func isConflict(err error) bool { var me *mcpclient.Error if errors.As(err, &me) && me.Code == -32003 { return true } return false } func marshalResult(r createResult) (json.RawMessage, error) { b, err := json.Marshal(r) if err != nil { return nil, fmt.Errorf("marshal result: %w", err) } return b, nil } func marshalPartial(r createResult, step string, inner error) (json.RawMessage, error) { r.FailedStep = step b, _ := json.Marshal(r) return b, fmt.Errorf("project_create step %q failed: %w", step, inner) }