|
|
|
@@ -48,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
|
|
|
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
|
|
|
|
return registry.ToolDescriptor{
|
|
|
|
|
Name: "create_project_from_template",
|
|
|
|
|
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finish substituting the placeholders manually. Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).",
|
|
|
|
|
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
|
|
|
|
|
InputSchema: json.RawMessage(`{
|
|
|
|
|
"type":"object",
|
|
|
|
|
"properties":{
|
|
|
|
@@ -56,8 +56,9 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
|
|
|
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
|
|
|
|
"description":{"type":"string"},
|
|
|
|
|
"private":{"type":"boolean"},
|
|
|
|
|
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."},
|
|
|
|
|
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."}
|
|
|
|
|
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template. Ignored when resume=true."},
|
|
|
|
|
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the project is opt-in for headless dispatch (dispatch#3). Default false. Safe to re-request on resume."},
|
|
|
|
|
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. Default false."}
|
|
|
|
|
},
|
|
|
|
|
"required":["owner","name"]
|
|
|
|
|
}`),
|
|
|
|
@@ -71,6 +72,7 @@ type createProjectArgs struct {
|
|
|
|
|
Private bool `json:"private"`
|
|
|
|
|
TemplateName string `json:"template_name"`
|
|
|
|
|
DispatchAllow bool `json:"dispatch_allow"`
|
|
|
|
|
Resume bool `json:"resume"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
|
|
|
@@ -86,6 +88,7 @@ type createProjectResult struct {
|
|
|
|
|
DefaultBranch string `json:"default_branch"`
|
|
|
|
|
FilesSubstituted []string `json:"files_substituted"`
|
|
|
|
|
PartialFailure string `json:"partial_failure,omitempty"`
|
|
|
|
|
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
|
|
|
@@ -104,6 +107,87 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|
|
|
|
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result createProjectResult
|
|
|
|
|
var branch string
|
|
|
|
|
var err error
|
|
|
|
|
if args.Resume {
|
|
|
|
|
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
|
|
|
|
|
} else {
|
|
|
|
|
result, branch, err = t.createDestination(ctx, args)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
|
|
|
|
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
|
|
|
|
// A fixed known-files list can't rename directories or cover every templated
|
|
|
|
|
// file, which is why the old scaffold didn't build. GetTree reflects the
|
|
|
|
|
// branch's CURRENT state, which is what makes this loop safe to re-run on
|
|
|
|
|
// resume: a file already fixed in a prior partial pass shows up already-correct
|
|
|
|
|
// (or already-renamed) and substituteEntry is a no-op for it.
|
|
|
|
|
repls := substitutions(args.Owner, args.Name)
|
|
|
|
|
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
|
|
|
|
if terr != nil {
|
|
|
|
|
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
|
|
|
|
|
return textOK(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, e := range tree.Tree {
|
|
|
|
|
if e.Type != "blob" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
|
|
|
|
if fail != "" {
|
|
|
|
|
result.PartialFailure = fail
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if substituted != "" {
|
|
|
|
|
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Opt the new project into headless dispatch if asked: presence of a
|
|
|
|
|
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
|
|
|
|
// (dispatch#3). Skip if substitution itself already stalled — don't mark an
|
|
|
|
|
// incomplete repo dispatch-eligible. A failure here is reported in its OWN
|
|
|
|
|
// field (gitea-mcp#51) — it must never be indistinguishable from a
|
|
|
|
|
// substitution failure, since one can succeed while the other doesn't.
|
|
|
|
|
if args.DispatchAllow && result.PartialFailure == "" {
|
|
|
|
|
didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch)
|
|
|
|
|
if fail != "" {
|
|
|
|
|
result.DispatchAllowFailure = fail
|
|
|
|
|
} else if didWrite {
|
|
|
|
|
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If substitution stalled because the generated branch wasn't writable in time,
|
|
|
|
|
// the repo IS created — say so clearly and point to the concrete recovery step
|
|
|
|
|
// (resume=true), rather than leaking the raw "branch does not exist" (infra#179:
|
|
|
|
|
// gitea's template-generate is slow-async on this instance, so tool-side
|
|
|
|
|
// substitution is best-effort).
|
|
|
|
|
if strings.Contains(result.PartialFailure, "branch does not exist") ||
|
|
|
|
|
strings.Contains(result.PartialFailure, "not found") {
|
|
|
|
|
result.PartialFailure = infra179FinalizeMessage(
|
|
|
|
|
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fail loud on a FRESH create with nothing substituted: a real template
|
|
|
|
|
// should have placeholders, so finding none is suspicious. On resume, nothing
|
|
|
|
|
// left to substitute is the expected steady state once a prior partial run is
|
|
|
|
|
// fully caught up — success, not a loud failure.
|
|
|
|
|
if !args.Resume && result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
|
|
|
|
|
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return textOK(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// createDestination generates a new repo from the template: verifies the
|
|
|
|
|
// template exists and the destination doesn't already exist, then calls
|
|
|
|
|
// gitea's /generate and resolves the default branch.
|
|
|
|
|
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
|
|
|
|
|
// Resolve template: per-call override takes precedence over the
|
|
|
|
|
// server-configured default. Owner stays server-configured.
|
|
|
|
|
tmplName := args.TemplateName
|
|
|
|
@@ -114,17 +198,19 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|
|
|
|
// Verify template exists and is marked as a template repo.
|
|
|
|
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("template lookup: %w", err)
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if !tmpl.Template {
|
|
|
|
|
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify destination doesn't already exist.
|
|
|
|
|
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
|
|
|
|
|
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf(
|
|
|
|
|
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
|
|
|
|
|
args.Owner, args.Name, gitea.ErrConflict)
|
|
|
|
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
|
|
|
|
return nil, fmt.Errorf("destination check: %w", err)
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf("destination check: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate repo from template.
|
|
|
|
@@ -136,7 +222,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|
|
|
|
GitContent: true,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("generate: %w", err)
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf("generate: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := createProjectResult{
|
|
|
|
@@ -158,83 +244,76 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
result.DefaultBranch = branch
|
|
|
|
|
return result, branch, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
|
|
|
|
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
|
|
|
|
// A fixed known-files list can't rename directories or cover every templated
|
|
|
|
|
// file, which is why the old scaffold didn't build.
|
|
|
|
|
repls := substitutions(args.Owner, args.Name)
|
|
|
|
|
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
|
|
|
|
// resumeDestination looks up an ALREADY-CREATED repo to continue substitution
|
|
|
|
|
// on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
|
|
|
|
|
// nothing to resume without it. Template lookup and generation are skipped
|
|
|
|
|
// entirely: resume only ever operates on the destination.
|
|
|
|
|
func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
|
|
|
|
|
repo, err := t.c.GetRepo(ctx, owner, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err)
|
|
|
|
|
return textOK(result)
|
|
|
|
|
if errors.Is(err, gitea.ErrNotFound) {
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf(
|
|
|
|
|
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
|
|
|
|
|
owner, name, gitea.ErrValidation)
|
|
|
|
|
}
|
|
|
|
|
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
|
|
|
|
|
}
|
|
|
|
|
branch := repo.DefaultBranch
|
|
|
|
|
if branch == "" {
|
|
|
|
|
branch = "main"
|
|
|
|
|
}
|
|
|
|
|
return createProjectResult{
|
|
|
|
|
FullName: repo.FullName,
|
|
|
|
|
HTMLURL: repo.HTMLURL,
|
|
|
|
|
CloneURL: repo.CloneURL,
|
|
|
|
|
DefaultBranch: branch,
|
|
|
|
|
}, branch, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, e := range tree.Tree {
|
|
|
|
|
if e.Type != "blob" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
|
|
|
|
if fail != "" {
|
|
|
|
|
result.PartialFailure = fail
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if substituted != "" {
|
|
|
|
|
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
|
|
|
|
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
|
|
|
|
|
// on every resume, not just the first attempt): creates it if absent, updates
|
|
|
|
|
// it if present but different, leaves it untouched if already correct. Without
|
|
|
|
|
// this, a naive create-only write would error on a re-invoke (gitea rejects a
|
|
|
|
|
// create at a path that already exists) — that was the reported failure in
|
|
|
|
|
// gitea-mcp#51. Returns whether a write actually happened.
|
|
|
|
|
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
|
|
|
|
|
const path = ".dispatch-allow"
|
|
|
|
|
sha := ""
|
|
|
|
|
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
|
|
|
|
|
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
|
|
|
|
|
return false, "" // already present and correct — idempotent no-op
|
|
|
|
|
}
|
|
|
|
|
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
|
|
|
|
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
|
|
|
|
return false, fmt.Sprintf("read %s: %v", path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Opt the new project into headless dispatch if asked: presence of a
|
|
|
|
|
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
|
|
|
|
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
|
|
|
|
|
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
|
|
|
|
|
// loop already stalled — a failed injection then degrades identically.
|
|
|
|
|
if args.DispatchAllow && result.PartialFailure == "" {
|
|
|
|
|
const dispatchAllowPath = ".dispatch-allow"
|
|
|
|
|
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
|
|
|
|
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
|
|
|
|
Branch: branch,
|
|
|
|
|
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
|
|
|
|
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
|
|
|
|
Sha: sha,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
|
|
|
|
|
} else {
|
|
|
|
|
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
|
|
|
|
|
return false, fmt.Sprintf("write %s: %v", path, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If substitution stalled because the generated branch wasn't writable in time,
|
|
|
|
|
// the repo IS created — say so clearly and point to the local finalize step,
|
|
|
|
|
// rather than leaking the raw "branch does not exist" (infra#179: gitea's
|
|
|
|
|
// template-generate is slow-async on this instance, so tool-side substitution
|
|
|
|
|
// is best-effort).
|
|
|
|
|
if strings.Contains(result.PartialFailure, "branch does not exist") ||
|
|
|
|
|
strings.Contains(result.PartialFailure, "not found") {
|
|
|
|
|
result.PartialFailure = infra179FinalizeMessage(
|
|
|
|
|
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fail loud: a scaffold that still holds placeholders does not build. Nothing
|
|
|
|
|
// substituted (with no explicit failure) means the walk found no placeholders —
|
|
|
|
|
// suspicious for a real template. Surface it instead of returning silent success.
|
|
|
|
|
if result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
|
|
|
|
|
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return textOK(result)
|
|
|
|
|
return true, ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
|
|
|
|
// async template-generate (infra#179) leaves the branch unwritable within the
|
|
|
|
|
// budget. It names the concrete remaining work — substituting the two
|
|
|
|
|
// placeholders — rather than pointing at a specific tool, so the guidance stays
|
|
|
|
|
// correct regardless of scaffolding-CLI state (gitea-mcp#46).
|
|
|
|
|
// budget. It points at the concrete recovery step — re-invoking this same tool
|
|
|
|
|
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
|
|
|
|
|
// resume safely continues from wherever substitution stalled.
|
|
|
|
|
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
|
|
|
|
|
"template-generate is slow-async on this instance (infra#179), so substitution "+
|
|
|
|
|
"is incomplete (%d file(s) done). Finish it by cloning the repo and replacing the "+
|
|
|
|
|
"remaining __PROJECT_NAME__ / __MODULE_PATH__ placeholders (in file contents and "+
|
|
|
|
|
"paths), then pushing; or retry create once the branch settles. Underlying: %s",
|
|
|
|
|
"is incomplete (%d file(s) done). Retry by calling this tool again with resume=true "+
|
|
|
|
|
"(same owner/name) once the branch is writable — it safely continues where this left "+
|
|
|
|
|
"off, skipping anything already correct. Underlying: %s",
|
|
|
|
|
branch, budget, done, underlying)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -243,7 +322,8 @@ func infra179FinalizeMessage(branch string, budget, done int, underlying string)
|
|
|
|
|
// before the branch ref is committed, so writes 404 "branch does not exist" for a
|
|
|
|
|
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
|
|
|
|
|
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
|
|
|
|
|
// fails fast and we defer substitution with clear guidance rather than hang.
|
|
|
|
|
// fails fast with partial_failure naming resume=true as the recovery path
|
|
|
|
|
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
|
|
|
|
|
const substitutionBudget = 5
|
|
|
|
|
|
|
|
|
|
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
|
|
|
|