feat(tools): create_project_from_template
Generates a new repo from mathias/template-go-web via Gitea's generate API, then substitutes __PROJECT_NAME__ and __MODULE_PATH__ placeholders in six known files (best-effort, partial failure surfaced in result). Validates name regex, allowlist, template flag, and destination non-existence before generating. Adds Template field to gitea.Repo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ func main() {
|
|||||||
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
||||||
|
|
||||||
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
mcpSrv := mcp.NewServer(mcp.ServerOptions{
|
||||||
Registry: reg,
|
Registry: reg,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Repo struct {
|
|||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
CloneURL string `json:"clone_url"`
|
CloneURL string `json:"clone_url"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
Template bool `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
|
||||||
|
|||||||
72
internal/gitea/templates.go
Normal file
72
internal/gitea/templates.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateFromTemplateArgs is the request body for POST /repos/{owner}/{repo}/generate.
|
||||||
|
type GenerateFromTemplateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
|
GitContent bool `json:"git_content"` // include all template files
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateFromTemplate creates a new repository from a template via POST /repos/{tmplOwner}/{tmplName}/generate.
|
||||||
|
func (c *Client) GenerateFromTemplate(ctx context.Context, tmplOwner, tmplName string, args GenerateFromTemplateArgs) (*Repo, error) {
|
||||||
|
p := fmt.Sprintf("/api/v1/repos/%s/%s/generate", tmplOwner, tmplName)
|
||||||
|
payload, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := c.PostJSON(ctx, p, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubstituteFile reads a file from the given branch, applies string replacements,
|
||||||
|
// and writes it back if any changes were made. Best-effort — returns a wrapped error
|
||||||
|
// that includes the file path.
|
||||||
|
func (c *Client) SubstituteFile(ctx context.Context, owner, repo, branch, path string, replacements map[string]string) error {
|
||||||
|
fc, err := c.GetFileContents(ctx, owner, repo, path, branch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode %s: %w", path, err)
|
||||||
|
}
|
||||||
|
content := string(decoded)
|
||||||
|
for k, v := range replacements {
|
||||||
|
content = strings.ReplaceAll(content, k, v)
|
||||||
|
}
|
||||||
|
if content == string(decoded) {
|
||||||
|
return nil // no changes, skip write
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
||||||
|
_, err = c.UpsertFile(ctx, owner, repo, path, UpsertFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Content: encoded,
|
||||||
|
Message: "Apply template substitutions",
|
||||||
|
Sha: fc.Sha,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
156
internal/gitea/templates_test.go
Normal file
156
internal/gitea/templates_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateFromTemplate(t *testing.T) {
|
||||||
|
var capturedBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/template-go-web/generate", r.URL.Path)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
var err error
|
||||||
|
capturedBody, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"name":"new-svc",
|
||||||
|
"full_name":"mathias/new-svc",
|
||||||
|
"default_branch":"main",
|
||||||
|
"description":"A new service",
|
||||||
|
"private":true,
|
||||||
|
"clone_url":"http://gitea.example.com/mathias/new-svc.git",
|
||||||
|
"html_url":"http://gitea.example.com/mathias/new-svc",
|
||||||
|
"template":false
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
repo, err := c.GenerateFromTemplate(context.Background(), "mathias", "template-go-web", gitea.GenerateFromTemplateArgs{
|
||||||
|
Owner: "mathias",
|
||||||
|
Name: "new-svc",
|
||||||
|
Description: "A new service",
|
||||||
|
Private: true,
|
||||||
|
GitContent: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the captured POST body contains the expected fields.
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(capturedBody, &payload))
|
||||||
|
assert.Equal(t, "mathias", payload["owner"])
|
||||||
|
assert.Equal(t, "new-svc", payload["name"])
|
||||||
|
assert.Equal(t, "A new service", payload["description"])
|
||||||
|
assert.Equal(t, true, payload["private"])
|
||||||
|
assert.Equal(t, true, payload["git_content"])
|
||||||
|
|
||||||
|
// Verify the decoded repo fields.
|
||||||
|
assert.Equal(t, "new-svc", repo.Name)
|
||||||
|
assert.Equal(t, "mathias/new-svc", repo.FullName)
|
||||||
|
assert.Equal(t, "main", repo.DefaultBranch)
|
||||||
|
assert.Equal(t, "A new service", repo.Description)
|
||||||
|
assert.True(t, repo.Private)
|
||||||
|
assert.Equal(t, "http://gitea.example.com/mathias/new-svc.git", repo.CloneURL)
|
||||||
|
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", repo.HTMLURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteFileApplies(t *testing.T) {
|
||||||
|
originalContent := "module __MODULE_PATH__\n\ngo 1.22\n"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
|
||||||
|
|
||||||
|
var capturedPutBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
|
||||||
|
assert.Equal(t, "main", r.URL.Query().Get("ref"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":30,"content":"` + encoded + `","encoding":"base64"}`))
|
||||||
|
case http.MethodPut:
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
|
||||||
|
var err error
|
||||||
|
capturedPutBody, err = io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"commitsha","html_url":""}}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected method %s", r.Method)
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the PUT body contains the substituted content.
|
||||||
|
require.NotNil(t, capturedPutBody, "PUT should have been called")
|
||||||
|
var payload map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(capturedPutBody, &payload))
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(payload["content"])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(decoded), "gitea.d-ma.be/mathias/new-svc")
|
||||||
|
assert.NotContains(t, string(decoded), "__MODULE_PATH__")
|
||||||
|
assert.Equal(t, "abc123", payload["sha"])
|
||||||
|
assert.Equal(t, "Apply template substitutions", payload["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteFileNoChangeSkipsWrite(t *testing.T) {
|
||||||
|
originalContent := "module gitea.d-ma.be/mathias/existing\n\ngo 1.22\n"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
|
||||||
|
|
||||||
|
var putCount atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":40,"content":"` + encoded + `","encoding":"base64"}`))
|
||||||
|
case http.MethodPut:
|
||||||
|
putCount.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
// Replacements that don't match anything in the content.
|
||||||
|
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(0), putCount.Load(), "PUT should not be called when content is unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteFileReadError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"file not found"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, gitea.ErrNotFound), "error should wrap ErrNotFound, got: %v", err)
|
||||||
|
}
|
||||||
146
internal/tools/create_project_from_template.go
Normal file
146
internal/tools/create_project_from_template.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||||
|
|
||||||
|
var substitutionFiles = []string{
|
||||||
|
"go.mod",
|
||||||
|
"Taskfile.yml",
|
||||||
|
"Dockerfile",
|
||||||
|
".gitea/workflows/cd.yml",
|
||||||
|
"README.md",
|
||||||
|
".context/PROJECT.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
func substitutions(owner, name string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"__PROJECT_NAME__": name,
|
||||||
|
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
||||||
|
type CreateProjectFromTemplate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
templateOwner string
|
||||||
|
templateName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplOwner, tmplName string) *CreateProjectFromTemplate {
|
||||||
|
return &CreateProjectFromTemplate{c: c, a: a, templateOwner: tmplOwner, templateName: tmplName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "create_project_from_template",
|
||||||
|
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
|
"description":{"type":"string"},
|
||||||
|
"private":{"type":"boolean"}
|
||||||
|
},
|
||||||
|
"required":["owner","name"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createProjectArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createProjectResult struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args createProjectArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist check first.
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name format.
|
||||||
|
if !nameRe.MatchString(args.Name) {
|
||||||
|
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify template exists and is marked as a template repo.
|
||||||
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("template lookup: %w", err)
|
||||||
|
}
|
||||||
|
if !tmpl.Template {
|
||||||
|
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, 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)
|
||||||
|
} else if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("destination check: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate repo from template.
|
||||||
|
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
||||||
|
Owner: args.Owner,
|
||||||
|
Name: args.Name,
|
||||||
|
Description: args.Description,
|
||||||
|
Private: args.Private,
|
||||||
|
GitContent: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := createProjectResult{
|
||||||
|
FullName: newRepo.FullName,
|
||||||
|
HTMLURL: newRepo.HTMLURL,
|
||||||
|
CloneURL: newRepo.CloneURL,
|
||||||
|
DefaultBranch: newRepo.DefaultBranch,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute placeholders in known files (best-effort).
|
||||||
|
repls := substitutions(args.Owner, args.Name)
|
||||||
|
branch := newRepo.DefaultBranch
|
||||||
|
for _, path := range substitutionFiles {
|
||||||
|
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
|
||||||
|
// Files that don't exist in this template are silently skipped.
|
||||||
|
if errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Any other error halts the substitution pass with partial_failure recorded.
|
||||||
|
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return textOK(result)
|
||||||
|
}
|
||||||
266
internal/tools/create_project_from_template_test.go
Normal file
266
internal/tools/create_project_from_template_test.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
|
||||||
|
var substitutionFileList = []string{
|
||||||
|
"go.mod",
|
||||||
|
"Taskfile.yml",
|
||||||
|
"Dockerfile",
|
||||||
|
".gitea/workflows/cd.yml",
|
||||||
|
"README.md",
|
||||||
|
".context/PROJECT.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentWithPlaceholder is a template file body that contains the placeholder.
|
||||||
|
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
|
||||||
|
|
||||||
|
func encodedContent(s string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileContentsJSON returns a JSON FileContents object for the given path.
|
||||||
|
func fileContentsJSON(path string) string {
|
||||||
|
enc := encodedContent(contentWithPlaceholder)
|
||||||
|
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
|
||||||
|
path, strings.ReplaceAll(path, "/", "-"), enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
|
||||||
|
func fileWriteResultJSON(path string) string {
|
||||||
|
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTemplateRepoJSON returns a JSON Repo marked as template.
|
||||||
|
func newTemplateRepoJSON(name string, isTemplate bool) string {
|
||||||
|
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||||
|
name, name, name, name, isTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
|
||||||
|
func newGeneratedRepoJSON(name string) string {
|
||||||
|
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||||
|
name, name, name, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
|
||||||
|
c := gitea.NewClient(srvURL, "tok")
|
||||||
|
a := allowlist.New([]string{"mathias"})
|
||||||
|
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectHappyPath: all 6 files served and substituted.
|
||||||
|
func TestCreateProjectHappyPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
// Template repo lookup
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
|
||||||
|
// Destination repo lookup — 404 means it doesn't exist yet
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
|
||||||
|
// Generate
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||||
|
|
||||||
|
// File contents GET — handle all 6 substitution files
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||||
|
|
||||||
|
// File contents PUT — handle all 6 substitution files
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
assert.Equal(t, "mathias/new-svc", out.FullName)
|
||||||
|
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
|
||||||
|
assert.Equal(t, "main", out.DefaultBranch)
|
||||||
|
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
|
||||||
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||||
|
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||||
|
tool := tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient("http://unused", ""),
|
||||||
|
allowlist.New([]string{"mathias"}),
|
||||||
|
"mathias", "template-go-web",
|
||||||
|
)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
|
||||||
|
func TestCreateProjectAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient("http://unused", ""),
|
||||||
|
allowlist.New([]string{"mathias"}),
|
||||||
|
"mathias", "template-go-web",
|
||||||
|
)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
|
||||||
|
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Template lookup returns a non-template repo.
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectDestinationExists: destination repo already exists.
|
||||||
|
func TestCreateProjectDestinationExists(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||||
|
// Destination exists — return 200.
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
|
||||||
|
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
|
||||||
|
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
|
||||||
|
// Files that should succeed (index 0-2 in substitutionFileList).
|
||||||
|
successFiles := map[string]bool{
|
||||||
|
"go.mod": true,
|
||||||
|
"Taskfile.yml": true,
|
||||||
|
"Dockerfile": true,
|
||||||
|
}
|
||||||
|
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
|
||||||
|
failFile := ".gitea/workflows/cd.yml"
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
||||||
|
if filePath == failFile {
|
||||||
|
// Simulate upstream 500.
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !successFiles[filePath] {
|
||||||
|
t.Errorf("unexpected PUT for file: %s", filePath)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
|
// Best-effort: no Go error returned, partial state in result.
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
|
||||||
|
// First 3 files should be in FilesSubstituted.
|
||||||
|
assert.Len(t, out.FilesSubstituted, 3)
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, failFile)
|
||||||
|
|
||||||
|
// partial_failure should be non-empty.
|
||||||
|
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user