Per the Gitea-as-true-master ADR (infra#34), GitHub mirror is now an
explicit opt-in via mirror_to_github=true. Default (omit / false) provisions
a Gitea repo + staging namespace + experiment-brief issue only — no GitHub
repo, no push-mirror.
Rationale: US cloud providers (Microsoft/GitHub) are subject to CLOUD Act
and NSL. Client code, business logic, and infra-adjacent repos should
never live on US-owned infrastructure. Only open-source projects intended
for public community (hyperguild, gitea-mcp, template-*) should opt in.
Changes
- internal/skills/project/handlers.go
- createArgs gains MirrorToGitHub bool (json:"mirror_to_github,omitempty").
- res.GitHubURL is set only when MirrorToGitHub is true; empty string otherwise.
- Steps 2 (create_github_repo) + 3 (mirror) are wrapped in `if args.MirrorToGitHub`.
- experimentBrief renders "Gitea-only" line by default and the existing
"Push-mirror configured" line only on opt-in.
- internal/skills/project/skill.go
- Tool schema gains mirror_to_github (boolean, default false) with description
spelling out when to opt in. Tool Description updated to reflect new default.
- internal/skills/project/handlers_test.go
- Added mirroredArgs() helper (happyArgs + mirror_to_github:true).
- Tests that exercise the GitHub flow (HappyPath, GitHubExists_Idempotent,
GitHubFails, NoGitHubClient_DegradedMode, Idempotent_RepoExists,
MirrorFails, InfraCommitFails) switched to mirroredArgs.
- Added TestProjectCreate_DefaultSkipsGitHubMirror covering the Gitea-only
path: 3 gitea-mcp calls, zero GitHub calls, empty github_url, reached=
[create_repo, infra_commit, issue], body reflects Gitea-only.
Closes gitea/mathias/hyperguild#17. Moves infra#34 acceptance item
"project_create updated: mirror_to_github defaults to false".
298 lines
9.6 KiB
Go
298 lines
9.6 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"`
|
|
MirrorToGitHub bool `json:"mirror_to_github,omitempty"`
|
|
}
|
|
|
|
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)
|
|
|
|
res := createResult{
|
|
GiteaURL: giteaURL,
|
|
}
|
|
if args.MirrorToGitHub {
|
|
res.GitHubURL = fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Steps 2+3 are skipped when MirrorToGitHub is false. Default per
|
|
// infra ADR (Gitea as true master, GitHub as optional opt-in): keep
|
|
// client / business-logic / personal repos Gitea-only. Set
|
|
// `mirror_to_github: true` for open-source projects that want a
|
|
// public GitHub mirror (hyperguild, gitea-mcp, template-*).
|
|
if args.MirrorToGitHub {
|
|
// 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.
|
|
if err := s.callInfraCommit(ctx, args.Name); 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 directly to infra
|
|
// main. Flux reconciles within ~60s. See DECISIONS.md 2026-05-18.
|
|
func (s *Skill) callInfraCommit(ctx context.Context, name 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": "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")
|
|
if args.MirrorToGitHub {
|
|
b.WriteString("- Push-mirror configured to GitHub.\n")
|
|
} else {
|
|
b.WriteString("- Gitea-only (no GitHub mirror — set `mirror_to_github: true` to opt in).\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)
|
|
}
|