|
|
|
@@ -8,6 +8,7 @@ import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
|
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
|
|
@@ -47,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, substituting placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/) so the result builds. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
|
|
|
|
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 finalize locally (`hyperguild new-project`). 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).",
|
|
|
|
|
InputSchema: json.RawMessage(`{
|
|
|
|
|
"type":"object",
|
|
|
|
|
"properties":{
|
|
|
|
@@ -55,7 +56,8 @@ 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."}
|
|
|
|
|
"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."}
|
|
|
|
|
},
|
|
|
|
|
"required":["owner","name"]
|
|
|
|
|
}`),
|
|
|
|
@@ -68,8 +70,15 @@ type createProjectArgs struct {
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
Private bool `json:"private"`
|
|
|
|
|
TemplateName string `json:"template_name"`
|
|
|
|
|
DispatchAllow bool `json:"dispatch_allow"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
|
|
|
|
// sandbox convention: presence of the file (not its content) marks the repo
|
|
|
|
|
// dispatch-eligible; the comment exists only to explain that to a human reader.
|
|
|
|
|
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
|
|
|
|
|
"# See dispatch#3.\n"
|
|
|
|
|
|
|
|
|
|
type createProjectResult struct {
|
|
|
|
|
FullName string `json:"full_name"`
|
|
|
|
|
HTMLURL string `json:"html_url"`
|
|
|
|
@@ -175,6 +184,39 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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{
|
|
|
|
|
Branch: branch,
|
|
|
|
|
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
|
|
|
|
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
|
|
|
|
|
} else {
|
|
|
|
|
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 = 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). Finalize locally with `hyperguild new-project` "+
|
|
|
|
|
"(clone + substitute, no API race). Underlying: %s",
|
|
|
|
|
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.
|
|
|
|
@@ -185,6 +227,35 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|
|
|
|
return textOK(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// substitutionBudget bounds how long we retry the first write while the freshly
|
|
|
|
|
// generated branch becomes writable. gitea's /generate returns (and serves reads)
|
|
|
|
|
// 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.
|
|
|
|
|
const substitutionBudget = 5
|
|
|
|
|
|
|
|
|
|
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
|
|
|
|
// exist" not-found, up to substitutionBudget. The write itself is the readiness
|
|
|
|
|
// probe — BranchExists reports the branch present before writes succeed.
|
|
|
|
|
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
|
|
|
|
|
var err error
|
|
|
|
|
for i := 0; i < substitutionBudget; i++ {
|
|
|
|
|
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, gitea.ErrNotFound) {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return err
|
|
|
|
|
case <-time.After(time.Second):
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// substituteEntry substitutes placeholders in one blob. If the path carries a
|
|
|
|
|
// placeholder it renames the file (write new + delete old); otherwise it rewrites
|
|
|
|
|
// content in place when changed. Returns a human-readable description of what was
|
|
|
|
@@ -212,7 +283,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
|
|
|
|
|
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
|
|
|
|
|
|
|
|
|
if renamed {
|
|
|
|
|
if _, err := t.c.UpsertFile(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
|
|
|
|
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
|
|
|
|
Branch: branch,
|
|
|
|
|
Content: enc,
|
|
|
|
|
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
|
|
|
|
@@ -229,7 +300,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
|
|
|
|
|
return path + " -> " + newPath, ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := t.c.UpsertFile(ctx, owner, name, path, gitea.UpsertFileArgs{
|
|
|
|
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
|
|
|
|
Branch: branch,
|
|
|
|
|
Content: enc,
|
|
|
|
|
Message: "template: substitute placeholders",
|
|
|
|
|