Compare commits
4 Commits
v0.2.4
...
d74b196db1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74b196db1 | ||
|
|
103194a11a | ||
|
|
4c87856aec | ||
|
|
b4176c1dec |
@@ -36,9 +36,6 @@ These rules apply to every task across every project, regardless of harness.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -49,7 +46,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
|
||||
@@ -71,7 +68,7 @@ Three machines on Tailscale:
|
||||
|
||||
| Machine | Role | Key specs |
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
|
||||
@@ -41,9 +41,6 @@ These rules apply to every task across every project, regardless of harness.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -54,7 +51,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
|
||||
@@ -76,7 +73,7 @@ Three machines on Tailscale:
|
||||
|
||||
| Machine | Role | Key specs |
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
|
||||
@@ -39,9 +39,6 @@ These rules apply to every task across every project, regardless of harness.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -52,7 +49,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
|
||||
@@ -74,7 +71,7 @@ Three machines on Tailscale:
|
||||
|
||||
| Machine | Role | Key specs |
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
|
||||
@@ -36,9 +36,6 @@ These rules apply to every task across every project, regardless of harness.
|
||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
||||
completion without evidence (tests pass, command output, observed behavior).
|
||||
5. **Branch-per-task for multi-agent repos.** When another agent may be active on
|
||||
the same repo, create a branch (`agent/<description>`), commit there, and open a
|
||||
PR. Do not merge without explicit instruction from Mathias.
|
||||
|
||||
## Default stack
|
||||
|
||||
@@ -49,7 +46,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Build | Task (taskfile.dev) | Make | — |
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| Search | Qdrant (vector), BM25 | — | — |
|
||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||
| Logging | slog (structured) | — | — |
|
||||
| Testing | Table-driven, testify | — | — |
|
||||
|
||||
@@ -71,7 +68,7 @@ Three machines on Tailscale:
|
||||
|
||||
| Machine | Role | Key specs |
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs llama-swap, Qdrant |
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func main() {
|
||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
||||
|
||||
@@ -86,3 +86,34 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// EditRepoArgs carries optional fields for PATCH /api/v1/repos/{owner}/{name}.
|
||||
// Pointer fields let the caller omit unset values from the wire payload, so the
|
||||
// server only patches what was explicitly requested.
|
||||
type EditRepoArgs struct {
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
Template *bool `json:"template,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) EditRepo(ctx context.Context, owner, name string, args EditRepoArgs) (*Repo, error) {
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal edit args: %w", err)
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r Repo
|
||||
if err := json.Unmarshal(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
@@ -45,14 +45,15 @@ 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 the template, applying placeholder substitutions to known files.",
|
||||
Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
||||
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"}
|
||||
"private":{"type":"boolean"},
|
||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
@@ -64,6 +65,7 @@ type createProjectArgs struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
TemplateName string `json:"template_name"`
|
||||
}
|
||||
|
||||
type createProjectResult struct {
|
||||
@@ -91,13 +93,20 @@ 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)
|
||||
}
|
||||
|
||||
// Resolve template: per-call override takes precedence over the
|
||||
// server-configured default. Owner stays server-configured.
|
||||
tmplName := args.TemplateName
|
||||
if tmplName == "" {
|
||||
tmplName = t.templateName
|
||||
}
|
||||
|
||||
// Verify template exists and is marked as a template repo.
|
||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
||||
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)
|
||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// Verify destination doesn't already exist.
|
||||
@@ -108,7 +117,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
||||
}
|
||||
|
||||
// Generate repo from template.
|
||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
|
||||
Owner: args.Owner,
|
||||
Name: args.Name,
|
||||
Description: args.Description,
|
||||
|
||||
@@ -122,6 +122,62 @@ func TestCreateProjectHappyPath(t *testing.T) {
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
}
|
||||
|
||||
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
||||
// server-configured default, so the same binary can generate from template-go-web or
|
||||
// template-go-agent without restart.
|
||||
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
||||
var templateLookups, generateCalls []string
|
||||
|
||||
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-agent":
|
||||
templateLookups = append(templateLookups, "template-go-agent")
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||
templateLookups = append(templateLookups, "template-go-web")
|
||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
||||
generateCalls = append(generateCalls, r.URL.Path)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/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()
|
||||
|
||||
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
||||
tool := newCreateProjectTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
||||
"override must direct the template lookup, not the server default")
|
||||
require.Len(t, generateCalls, 1)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
||||
"override must direct the /generate call too")
|
||||
}
|
||||
|
||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||
tool := tools.NewCreateProjectFromTemplate(
|
||||
|
||||
@@ -143,7 +143,13 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
|
||||
|
||||
flush := func() {
|
||||
if currentFile != "" {
|
||||
m[currentFile] = current.Bytes()
|
||||
// Copy: bytes.Buffer.Bytes() returns the internal slice,
|
||||
// which Reset() then reuses. Without the copy, every map
|
||||
// entry ends up aliased to the last file's data.
|
||||
b := current.Bytes()
|
||||
cp := make([]byte, len(b))
|
||||
copy(cp, b)
|
||||
m[currentFile] = cp
|
||||
current.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,47 @@ func TestPRFilesDiffSmall(t *testing.T) {
|
||||
assert.ElementsMatch(t, fileNames, paths)
|
||||
}
|
||||
|
||||
// Regression for issue #25: every file's diff entry must contain its OWN diff,
|
||||
// not a shared buffer pointing at the last file. Prior bug: splitUnifiedDiff
|
||||
// flushed bytes.Buffer.Bytes() into the map without copying, so every entry
|
||||
// aliased the buffer's backing array and showed the last file's content.
|
||||
func TestPRFilesDiffPerFileIsolation(t *testing.T) {
|
||||
fileNames := []string{"alpha.go", "beta.go", "gamma.go", "delta.go"}
|
||||
rawDiff := buildDiff(fileNames, 5)
|
||||
filesJSON := buildFilesJSON(fileNames, 5)
|
||||
|
||||
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Diff string `json:"diff"`
|
||||
} `json:"files"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(result, &out))
|
||||
require.Len(t, out.Files, len(fileNames))
|
||||
|
||||
for _, f := range out.Files {
|
||||
expected := fmt.Sprintf("diff --git a/%s b/%s", f.Path, f.Path)
|
||||
assert.Contains(t, f.Diff, expected,
|
||||
"file %s diff must contain its own header, got: %.80q", f.Path, f.Diff)
|
||||
// No other file's header should leak in.
|
||||
for _, other := range fileNames {
|
||||
if other == f.Path {
|
||||
continue
|
||||
}
|
||||
otherHeader := fmt.Sprintf("diff --git a/%s b/%s", other, other)
|
||||
assert.NotContains(t, f.Diff, otherHeader,
|
||||
"file %s diff must NOT contain %s's header", f.Path, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||
fileNames := []string{"bigfile.go"}
|
||||
|
||||
81
internal/tools/repo_update.go
Normal file
81
internal/tools/repo_update.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type RepoUpdate struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
|
||||
return &RepoUpdate{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "repo_update",
|
||||
Description: "Update repository metadata via PATCH (archived, description, private, website, template). " +
|
||||
"Only fields explicitly set in the call are patched. " +
|
||||
"WARNING: private=false exposes the repo publicly — verify intent before calling.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"archived":{"type":"boolean","description":"Mark repo as archived (read-only). Reversible."},
|
||||
"description":{"type":"string"},
|
||||
"private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
|
||||
"website":{"type":"string","description":"Homepage URL"},
|
||||
"template":{"type":"boolean","description":"Toggle template-repo flag"}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type repoUpdateArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
Template *bool `json:"template,omitempty"`
|
||||
}
|
||||
|
||||
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args repoUpdateArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Name == "" {
|
||||
return nil, fmt.Errorf("name required: %w", gitea.ErrValidation)
|
||||
}
|
||||
if args.Archived == nil && args.Description == nil && args.Private == nil &&
|
||||
args.Website == nil && args.Template == nil {
|
||||
return nil, fmt.Errorf("at least one updatable field must be set: %w", gitea.ErrValidation)
|
||||
}
|
||||
|
||||
updated, err := t.c.EditRepo(ctx, args.Owner, args.Name, gitea.EditRepoArgs{
|
||||
Archived: args.Archived,
|
||||
Description: args.Description,
|
||||
Private: args.Private,
|
||||
Website: args.Website,
|
||||
Template: args.Template,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("edit repo: %w", err)
|
||||
}
|
||||
return textOK(updated)
|
||||
}
|
||||
139
internal/tools/repo_update_test.go
Normal file
139
internal/tools/repo_update_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"
|
||||
)
|
||||
|
||||
func newRepoUpdateTool(srvURL string) *tools.RepoUpdate {
|
||||
return tools.NewRepoUpdate(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
}
|
||||
|
||||
// TestRepoUpdateArchive: happy path — set archived=true.
|
||||
func TestRepoUpdateArchive(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPatch, r.Method)
|
||||
require.Equal(t, "/api/v1/repos/mathias/old-svc", r.URL.Path)
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"old-svc","archived":true}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wire payload only contains the field that was actually set.
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, true, sent["archived"])
|
||||
assert.NotContains(t, sent, "description")
|
||||
assert.NotContains(t, sent, "private")
|
||||
assert.NotContains(t, sent, "website")
|
||||
assert.NotContains(t, sent, "template")
|
||||
|
||||
var repo gitea.Repo
|
||||
require.NoError(t, json.Unmarshal(result, &repo))
|
||||
assert.Equal(t, "mathias/old-svc", repo.FullName)
|
||||
}
|
||||
|
||||
// TestRepoUpdateMultipleFields: set description + template flag in one call.
|
||||
func TestRepoUpdateMultipleFields(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"template-go-agent","full_name":"mathias/template-go-agent","description":"Go agent template","template":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"template-go-agent","description":"Go agent template","template":true}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, "Go agent template", sent["description"])
|
||||
assert.Equal(t, true, sent["template"])
|
||||
assert.NotContains(t, sent, "archived")
|
||||
assert.NotContains(t, sent, "private")
|
||||
}
|
||||
|
||||
// TestRepoUpdateNoFieldsRejected: zero updatable fields → validation error before network.
|
||||
func TestRepoUpdateNoFieldsRejected(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"some-repo"}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||
}
|
||||
|
||||
// TestRepoUpdateMakePublic: explicit private=false is allowed; wire payload carries the false.
|
||||
// (The destructive nature is warned about in the tool description, not blocked by the tool.)
|
||||
func TestRepoUpdateMakePublic(t *testing.T) {
|
||||
var patchedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
patchedBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"open-repo","full_name":"mathias/open-repo","private":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"open-repo","private":false}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
var sent map[string]any
|
||||
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||
assert.Equal(t, false, sent["private"])
|
||||
}
|
||||
|
||||
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
||||
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewRepoUpdate(
|
||||
gitea.NewClient("http://unused", ""),
|
||||
allowlist.New([]string{"mathias"}),
|
||||
)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"evil","name":"some-repo","archived":true}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestRepoUpdateUpstreamError: server 500 propagates as ErrUpstream.
|
||||
func TestRepoUpdateUpstreamError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := newRepoUpdateTool(srv.URL)
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||
`{"owner":"mathias","name":"some-repo","archived":true}`,
|
||||
))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, gitea.ErrUpstream)
|
||||
}
|
||||
Reference in New Issue
Block a user