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.
287 lines
9.0 KiB
Go
287 lines
9.0 KiB
Go
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)
|
|
}
|