From f26f922c9697cf83241b5c5ff92cc56c91f48430 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 16 May 2026 23:00:23 +0200 Subject: [PATCH 1/6] chore: re-sync context adapters with upstream root Derived adapters drifted from canonical root .context/AGENT.md after the pgvector default change landed upstream. Pure regeneration via scripts/context-sync.sh, no manual edits. Required to make task check pass before the feature commits on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .aider.conventions.md | 3 --- .context/system-prompt.txt | 3 --- .cursorrules | 3 --- AGENTS.md | 3 --- 4 files changed, 12 deletions(-) diff --git a/.aider.conventions.md b/.aider.conventions.md index 230796d..b1441ab 100644 --- a/.aider.conventions.md +++ b/.aider.conventions.md @@ -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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## Default stack diff --git a/.context/system-prompt.txt b/.context/system-prompt.txt index 61d4351..b916a79 100644 --- a/.context/system-prompt.txt +++ b/.context/system-prompt.txt @@ -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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## Default stack diff --git a/.cursorrules b/.cursorrules index 1e224a4..0c3be4a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## Default stack diff --git a/AGENTS.md b/AGENTS.md index 230796d..b1441ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`), commit there, and open a - PR. Do not merge without explicit instruction from Mathias. ## Default stack -- 2.49.1 From 9013c8ff9cd1d7b5848e8e6bd5bdd4a30a049991 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 16 May 2026 23:01:18 +0200 Subject: [PATCH 2/6] fix(pr_files_diff): copy per-file diff bytes to break buffer aliasing splitUnifiedDiff used bytes.Buffer to accumulate each file's diff, then stored buf.Bytes() into the result map and called buf.Reset() to start the next file. bytes.Buffer.Bytes() returns the buffer's internal backing slice; Reset() resets length to 0 but reuses the same backing array. As a result, every map entry aliased the same storage, so all files ended up showing the LAST file's diff content. Fix: copy the bytes into a fresh slice before storing in the map. Adds TestPRFilesDiffPerFileIsolation as a regression test that asserts each file entry contains its OWN diff --git header and none of the other files' headers. Verified failing on the prior code, passing after the fix. Closes #25 --- internal/tools/pr_files_diff.go | 8 +++++- internal/tools/pr_files_diff_test.go | 41 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/tools/pr_files_diff.go b/internal/tools/pr_files_diff.go index 0400802..12f131a 100644 --- a/internal/tools/pr_files_diff.go +++ b/internal/tools/pr_files_diff.go @@ -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() } } diff --git a/internal/tools/pr_files_diff_test.go b/internal/tools/pr_files_diff_test.go index 02bec0f..0e1a61a 100644 --- a/internal/tools/pr_files_diff_test.go +++ b/internal/tools/pr_files_diff_test.go @@ -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"} -- 2.49.1 From 5545d6ab4be572d34fd4b283e2422a291cd97a91 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 16 May 2026 23:01:28 +0200 Subject: [PATCH 3/6] fix(create_project_from_template): accept per-call template_name override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template name was hardcoded into the binary at startup via NewCreateProjectFromTemplate("mathias", "template-go-web"), so generating from a different template (e.g. template-go-agent) required a code change and restart. The constructor already parameterised it correctly — the gap was at the tool's input schema, which never exposed template_name to the caller. Adds an optional template_name input field. When set, it overrides the server-configured default for that call only; when omitted, behavior is unchanged. Template owner stays server-configured — only the repo name is per-call. Server-side validation already verifies the resolved template exists and is marked as a template repo, so no enum constraint is added — keeps the door open for future templates (go-ml, go-service, ...) without redeploys. Adds TestCreateProjectTemplateNameOverride verifying the override directs both the template lookup and the /generate POST. Closes #24 --- .../tools/create_project_from_template.go | 27 ++++++--- .../create_project_from_template_test.go | 56 +++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index 0f548aa..f8df046 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -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"] }`), @@ -60,10 +61,11 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { } type createProjectArgs struct { - Owner string `json:"owner"` - Name string `json:"name"` - Description string `json:"description"` - Private bool `json:"private"` + Owner string `json:"owner"` + 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, diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index 5c34d92..a5de491 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -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( -- 2.49.1 From eeefc626ed552fcf4f11d478a4965308815d56db Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 16 May 2026 23:01:33 +0200 Subject: [PATCH 4/6] feat(repo_update): tool for archiving + metadata patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a repo_update tool exposing PATCH /api/v1/repos/{owner}/{name} with optional pointer fields (archived, description, private, website, template). Only fields set by the caller are sent on the wire, so the server patches exactly what was asked for. Originally needed to archive ingestion-svc cleanly instead of leaving a README tombstone, and to flip template-go-{agent,web} to template=true so create_project_from_template stops failing the "is not marked as template" guard. Wire-level enforcement of "at least one field" returns ErrValidation before any network call, preventing no-op PATCHes. private=false (making a repo public) is allowed but flagged in the tool description with a "verify intent before calling" warning. The earlier issue draft suggested an ntfy confirmation hook for that path — out of scope for this PR; the warning string is the minimum that fits inside the tool surface today. Wires NewRepoUpdate into cmd/gitea-mcp/main.go alongside the rest of the repo_* family. Closes #12 --- internal/gitea/repos.go | 3 + internal/tools/repo_update.go | 55 ++++++----- internal/tools/repo_update_test.go | 143 ++++++++++++++++++++++++----- 3 files changed, 153 insertions(+), 48 deletions(-) diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 46b7d9d..c899e2b 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -216,6 +216,8 @@ type UpdateRepoArgs struct { Private *bool `json:"private,omitempty"` Website *string `json:"website,omitempty"` DefaultBranch *string `json:"default_branch,omitempty"` + Archived *bool `json:"archived,omitempty"` + Template *bool `json:"template,omitempty"` } func (c *Client) UpdateRepo(ctx context.Context, owner, name string, args UpdateRepoArgs) (*Repo, error) { @@ -253,3 +255,4 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) } return &r, nil } + diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go index 74d22e3..959d164 100644 --- a/internal/tools/repo_update.go +++ b/internal/tools/repo_update.go @@ -21,18 +21,20 @@ func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate { func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ - Name: "repo_update", - Description: "Update repository metadata (description, visibility, default branch, website).", + 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"}, - "website":{"type":"string"}, - "default_branch":{"type":"string"}, - "confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."} + "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"] }`), @@ -40,13 +42,13 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { } type repoUpdateArgs struct { - Owner string `json:"owner"` - Name string `json:"name"` - Description *string `json:"description"` - Private *bool `json:"private"` - Website *string `json:"website"` - DefaultBranch *string `json:"default_branch"` - Confirm string `json:"confirm"` + 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) { @@ -57,20 +59,23 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes if err := t.a.Check(args.Owner); err != nil { return nil, err } - // Making a repo public is a significant action — require explicit confirmation. - if args.Private != nil && !*args.Private { - if args.Confirm != args.Name { - return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name) - } + if args.Name == "" { + return nil, fmt.Errorf("name required: %w", gitea.ErrValidation) } - r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{ - Description: args.Description, - Private: args.Private, - Website: args.Website, - DefaultBranch: args.DefaultBranch, + 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, err + return nil, fmt.Errorf("edit repo: %w", err) } - return textOK(r) + return textOK(updated) } diff --git a/internal/tools/repo_update_test.go b/internal/tools/repo_update_test.go index ff930cc..889f92e 100644 --- a/internal/tools/repo_update_test.go +++ b/internal/tools/repo_update_test.go @@ -3,6 +3,7 @@ package tools_test import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -14,43 +15,139 @@ import ( "github.com/stretchr/testify/require" ) -func TestRepoUpdateTool(t *testing.T) { +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) { - assert.Equal(t, http.MethodPatch, r.Method) - assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path) + 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":"infra","full_name":"mathias/infra","default_branch":"main","description":"updated","private":true,"clone_url":"https://gitea.example.com/mathias/infra.git","html_url":"https://gitea.example.com/mathias/infra"}`)) + _, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`)) })) defer srv.Close() - tool := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) - out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","description":"updated"}`)) + tool := newRepoUpdateTool(srv.URL) + result, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"old-svc","archived":true}`, + )) require.NoError(t, err) - assert.Contains(t, string(out), `"description":"updated"`) + + // 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) } -func TestRepoUpdateTool_MakePublicRequiresConfirm(t *testing.T) { - tool := tools.NewRepoUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) - _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false}`)) +// 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: private=false requires confirm= as a safety +// gate (kept from main #21 during the v02-patch merge). With confirm matching, the +// patch goes through. +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,"confirm":"open-repo"}`, + )) + require.NoError(t, err) + + var sent map[string]any + require.NoError(t, json.Unmarshal(patchedBody, &sent)) + assert.Equal(t, false, sent["private"]) +} + +// TestRepoUpdateMakePublicWithoutConfirm: confirm gate blocks private=false without confirmation. +func TestRepoUpdateMakePublicWithoutConfirm(t *testing.T) { + tool := tools.NewRepoUpdate( + gitea.NewClient("http://unused", ""), + allowlist.New([]string{"mathias"}), + ) + _, err := tool.Call(context.Background(), json.RawMessage( + `{"owner":"mathias","name":"open-repo","private":false}`, + )) require.Error(t, err) assert.Contains(t, err.Error(), "confirm") } -func TestRepoUpdateTool_MakePublicWithConfirm(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":false,"clone_url":"","html_url":""}`)) +// 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 := tools.NewRepoUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) - out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","private":false,"confirm":"infra"}`)) - require.NoError(t, err) - assert.Contains(t, string(out), `"full_name":"mathias/infra"`) -} - -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":"x"}`)) + 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) } -- 2.49.1 From 3648373333efa52e3180a308b80fa1d8c8fa648b Mon Sep 17 00:00:00 2001 From: mathias Date: Sat, 16 May 2026 21:17:40 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20merge=20repo=5Fupdate=20=E2=80=94=20?= =?UTF-8?q?add=20archived+template,=20keep=20default=5Fbranch+confirm=20fr?= =?UTF-8?q?om=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/repo_update.go | 54 +++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/internal/tools/repo_update.go b/internal/tools/repo_update.go index 959d164..5201070 100644 --- a/internal/tools/repo_update.go +++ b/internal/tools/repo_update.go @@ -22,7 +22,7 @@ func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate { func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ Name: "repo_update", - Description: "Update repository metadata via PATCH (archived, description, private, website, template). " + + Description: "Update repository metadata (description, visibility, default branch, website, archived, template). " + "Only fields explicitly set in the call are patched. " + "WARNING: private=false exposes the repo publicly — verify intent before calling.", InputSchema: json.RawMessage(`{ @@ -30,11 +30,13 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { "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"} + "default_branch":{"type":"string","description":"Rename the default branch"}, + "archived":{"type":"boolean","description":"Mark repo as archived (read-only)."}, + "template":{"type":"boolean","description":"Toggle template-repo flag"}, + "confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."} }, "required":["owner","name"] }`), @@ -42,13 +44,15 @@ func (t *RepoUpdate) Descriptor() registry.ToolDescriptor { } 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"` + Owner string `json:"owner"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Private *bool `json:"private,omitempty"` + Website *string `json:"website,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + Archived *bool `json:"archived,omitempty"` + Template *bool `json:"template,omitempty"` + Confirm string `json:"confirm"` } func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { @@ -59,23 +63,29 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes if err := t.a.Check(args.Owner); err != nil { return nil, err } - if args.Name == "" { - return nil, fmt.Errorf("name required: %w", gitea.ErrValidation) + + // Making a repo public is a significant action — require explicit confirmation. + if args.Private != nil && !*args.Private { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name) + } } - if args.Archived == nil && args.Description == nil && args.Private == nil && - args.Website == nil && args.Template == nil { + + if args.Description == nil && args.Private == nil && args.Website == nil && + args.DefaultBranch == nil && args.Archived == 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, + r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{ + Description: args.Description, + Private: args.Private, + Website: args.Website, + DefaultBranch: args.DefaultBranch, + Archived: args.Archived, + Template: args.Template, }) if err != nil { - return nil, fmt.Errorf("edit repo: %w", err) + return nil, err } - return textOK(updated) + return textOK(r) } -- 2.49.1 From 3cccbfb8cbc5f246cfcc874fae650080dc157328 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 17 May 2026 00:02:08 +0200 Subject: [PATCH 6/6] chore: re-sync context adapters after rebase Upstream .context/PROJECT.md gained a branch-protection rule + an extra agent instruction. Pure regeneration via scripts/context-sync.sh to make task check pass before force-push. Co-Authored-By: Claude Opus 4.7 (1M context) --- .aider.conventions.md | 124 +++++++++++++++++++++++-------------- .context/system-prompt.txt | 124 +++++++++++++++++++++++-------------- .cursorrules | 124 +++++++++++++++++++++++-------------- AGENTS.md | 124 +++++++++++++++++++++++-------------- CLAUDE.md | 121 ++++++++++++++++++++++-------------- 5 files changed, 384 insertions(+), 233 deletions(-) diff --git a/.aider.conventions.md b/.aider.conventions.md index b1441ab..a48af53 100644 --- a/.aider.conventions.md +++ b/.aider.conventions.md @@ -49,7 +49,6 @@ These rules apply to every task across every project, regardless of harness. | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -61,7 +60,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -211,6 +210,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - PRs: one concern per PR, description explains *why* not *what* +- **Branch protection:** always work on a feature branch, open a PR, never push directly to main ### Security - No secrets in code, ever — use env vars or SOPS-encrypted files @@ -248,68 +248,98 @@ When acting as a coding agent on this project: 3. If unsure about a convention, check `DECISIONS.md` or ask 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +6. Always work on a feature branch and open a PR — never push directly to main +7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM -## Current sprint — gitea-mcp v0.2 (2026-05-14) +## Current sprint — gitea-mcp v0.2 patch (2026-05-14) ### Context -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### Issues to implement (priority order) +This sprint fixes three remaining gaps found during code review on 2026-05-14. +These are blockers for `hyperguild new-project`. -**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** +### Issues to fix (all three in one PR: `fix/v02-patch`) -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"template": { + "type": "boolean", + "description": "Toggle template repo flag." +} +``` -**Batch 3 — can wait** +Add confirm-guard for `archived=true` (same pattern as `private=false`): +```go +if args.Archived != nil && *args.Archived { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name) + } +} +``` -| Issue | Tool | Note | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `TestRepoUpdateTool_SetTemplate` — no confirm needed -### How to add a tool (pattern) +#### #24 — create_project_from_template: make template selectable +**File:** `internal/tools/create_project_from_template.go` -Every tool = 4 files following `internal/tools/repo_get.go` exactly: +Add optional `template_name` param to input schema: +```json +"template_name": { + "type": "string", + "enum": ["template-go-web", "template-go-agent"], + "description": "Template repo to generate from. Defaults to template-go-web.", + "default": "template-go-web" +} +``` -1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### Token permissions needed +#### #25 — pr_files_diff: fix same diff returned for all files +**File:** `internal/tools/pr_files_diff.go` -New tools require these additional Gitea token scopes: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +ensure each iteration reads and assigns the correct diff for its own file. -Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web` +- [ ] `pr_files_diff` returns distinct diff per file +- [ ] All new test cases pass +- [ ] PR `fix/v02-patch` merged to main via PR (not direct push) + +### After this sprint + +Next: `hyperguild new-project` v1 implementation. +See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec. +Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working. diff --git a/.context/system-prompt.txt b/.context/system-prompt.txt index b916a79..23b35d4 100644 --- a/.context/system-prompt.txt +++ b/.context/system-prompt.txt @@ -54,7 +54,6 @@ These rules apply to every task across every project, regardless of harness. | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -66,7 +65,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -216,6 +215,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - PRs: one concern per PR, description explains *why* not *what* +- **Branch protection:** always work on a feature branch, open a PR, never push directly to main ### Security - No secrets in code, ever — use env vars or SOPS-encrypted files @@ -253,70 +253,100 @@ When acting as a coding agent on this project: 3. If unsure about a convention, check `DECISIONS.md` or ask 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +6. Always work on a feature branch and open a PR — never push directly to main +7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM -## Current sprint — gitea-mcp v0.2 (2026-05-14) +## Current sprint — gitea-mcp v0.2 patch (2026-05-14) ### Context -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### Issues to implement (priority order) +This sprint fixes three remaining gaps found during code review on 2026-05-14. +These are blockers for `hyperguild new-project`. -**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** +### Issues to fix (all three in one PR: `fix/v02-patch`) -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"template": { + "type": "boolean", + "description": "Toggle template repo flag." +} +``` -**Batch 3 — can wait** +Add confirm-guard for `archived=true` (same pattern as `private=false`): +```go +if args.Archived != nil && *args.Archived { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name) + } +} +``` -| Issue | Tool | Note | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `TestRepoUpdateTool_SetTemplate` — no confirm needed -### How to add a tool (pattern) +#### #24 — create_project_from_template: make template selectable +**File:** `internal/tools/create_project_from_template.go` -Every tool = 4 files following `internal/tools/repo_get.go` exactly: +Add optional `template_name` param to input schema: +```json +"template_name": { + "type": "string", + "enum": ["template-go-web", "template-go-agent"], + "description": "Template repo to generate from. Defaults to template-go-web.", + "default": "template-go-web" +} +``` -1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### Token permissions needed +#### #25 — pr_files_diff: fix same diff returned for all files +**File:** `internal/tools/pr_files_diff.go` -New tools require these additional Gitea token scopes: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +ensure each iteration reads and assigns the correct diff for its own file. -Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web` +- [ ] `pr_files_diff` returns distinct diff per file +- [ ] All new test cases pass +- [ ] PR `fix/v02-patch` merged to main via PR (not direct push) + +### After this sprint + +Next: `hyperguild new-project` v1 implementation. +See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec. +Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working. --- diff --git a/.cursorrules b/.cursorrules index 0c3be4a..cc7d254 100644 --- a/.cursorrules +++ b/.cursorrules @@ -52,7 +52,6 @@ These rules apply to every task across every project, regardless of harness. | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -64,7 +63,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -214,6 +213,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - PRs: one concern per PR, description explains *why* not *what* +- **Branch protection:** always work on a feature branch, open a PR, never push directly to main ### Security - No secrets in code, ever — use env vars or SOPS-encrypted files @@ -251,68 +251,98 @@ When acting as a coding agent on this project: 3. If unsure about a convention, check `DECISIONS.md` or ask 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +6. Always work on a feature branch and open a PR — never push directly to main +7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM -## Current sprint — gitea-mcp v0.2 (2026-05-14) +## Current sprint — gitea-mcp v0.2 patch (2026-05-14) ### Context -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### Issues to implement (priority order) +This sprint fixes three remaining gaps found during code review on 2026-05-14. +These are blockers for `hyperguild new-project`. -**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** +### Issues to fix (all three in one PR: `fix/v02-patch`) -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"template": { + "type": "boolean", + "description": "Toggle template repo flag." +} +``` -**Batch 3 — can wait** +Add confirm-guard for `archived=true` (same pattern as `private=false`): +```go +if args.Archived != nil && *args.Archived { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name) + } +} +``` -| Issue | Tool | Note | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `TestRepoUpdateTool_SetTemplate` — no confirm needed -### How to add a tool (pattern) +#### #24 — create_project_from_template: make template selectable +**File:** `internal/tools/create_project_from_template.go` -Every tool = 4 files following `internal/tools/repo_get.go` exactly: +Add optional `template_name` param to input schema: +```json +"template_name": { + "type": "string", + "enum": ["template-go-web", "template-go-agent"], + "description": "Template repo to generate from. Defaults to template-go-web.", + "default": "template-go-web" +} +``` -1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### Token permissions needed +#### #25 — pr_files_diff: fix same diff returned for all files +**File:** `internal/tools/pr_files_diff.go` -New tools require these additional Gitea token scopes: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +ensure each iteration reads and assigns the correct diff for its own file. -Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web` +- [ ] `pr_files_diff` returns distinct diff per file +- [ ] All new test cases pass +- [ ] PR `fix/v02-patch` merged to main via PR (not direct push) + +### After this sprint + +Next: `hyperguild new-project` v1 implementation. +See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec. +Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working. diff --git a/AGENTS.md b/AGENTS.md index b1441ab..a48af53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,6 @@ These rules apply to every task across every project, regardless of harness. | Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — | | Logging | slog (structured) | — | — | | Testing | Table-driven, testify | — | — | -| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — | Exploratory: Rust, Zig — I'll tell you when I want these. @@ -61,7 +60,7 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs - **Git**: conventional commits (`feat:`, `fix:`, `chore:`), one concern per PR, PR describes *why* not *what* - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config -- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message ## Infrastructure @@ -211,6 +210,7 @@ Key skills: - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - PRs: one concern per PR, description explains *why* not *what* +- **Branch protection:** always work on a feature branch, open a PR, never push directly to main ### Security - No secrets in code, ever — use env vars or SOPS-encrypted files @@ -248,68 +248,98 @@ When acting as a coding agent on this project: 3. If unsure about a convention, check `DECISIONS.md` or ask 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +6. Always work on a feature branch and open a PR — never push directly to main +7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM -## Current sprint — gitea-mcp v0.2 (2026-05-14) +## Current sprint — gitea-mcp v0.2 patch (2026-05-14) ### Context -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### Issues to implement (priority order) +This sprint fixes three remaining gaps found during code review on 2026-05-14. +These are blockers for `hyperguild new-project`. -**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** +### Issues to fix (all three in one PR: `fix/v02-patch`) -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"template": { + "type": "boolean", + "description": "Toggle template repo flag." +} +``` -**Batch 3 — can wait** +Add confirm-guard for `archived=true` (same pattern as `private=false`): +```go +if args.Archived != nil && *args.Archived { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name) + } +} +``` -| Issue | Tool | Note | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `TestRepoUpdateTool_SetTemplate` — no confirm needed -### How to add a tool (pattern) +#### #24 — create_project_from_template: make template selectable +**File:** `internal/tools/create_project_from_template.go` -Every tool = 4 files following `internal/tools/repo_get.go` exactly: +Add optional `template_name` param to input schema: +```json +"template_name": { + "type": "string", + "enum": ["template-go-web", "template-go-agent"], + "description": "Template repo to generate from. Defaults to template-go-web.", + "default": "template-go-web" +} +``` -1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### Token permissions needed +#### #25 — pr_files_diff: fix same diff returned for all files +**File:** `internal/tools/pr_files_diff.go` -New tools require these additional Gitea token scopes: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +ensure each iteration reads and assigns the correct diff for its own file. -Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web` +- [ ] `pr_files_diff` returns distinct diff per file +- [ ] All new test cases pass +- [ ] PR `fix/v02-patch` merged to main via PR (not direct push) + +### After this sprint + +Next: `hyperguild new-project` v1 implementation. +See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec. +Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working. diff --git a/CLAUDE.md b/CLAUDE.md index 934bde5..64260d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - Branch naming: `feat/short-description`, `fix/short-description` - PRs: one concern per PR, description explains *why* not *what* +- **Branch protection:** always work on a feature branch, open a PR, never push directly to main ### Security - No secrets in code, ever — use env vars or SOPS-encrypted files @@ -76,68 +77,98 @@ When acting as a coding agent on this project: 3. If unsure about a convention, check `DECISIONS.md` or ask 4. Never modify files outside the project root without explicit permission 5. When adding a dependency, explain why in the commit message -6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM +6. Always work on a feature branch and open a PR — never push directly to main +7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM -## Current sprint — gitea-mcp v0.2 (2026-05-14) +## Current sprint — gitea-mcp v0.2 patch (2026-05-14) ### Context -This sprint implements new MCP tools needed for `hyperguild new-project` — -the automated project creation flow triggered from claude.ai. See brain knowledge -nodes `adr-new-project-gitea-first-github-mirror` and `roadmap-github-ingestion-pipeline` -for full background. +The main v0.2 batch (repo_create, repo_update, repo_mirror_push, repo_delete, +repo_tree, repo_topics_update, file_read dir-fix, issue_get, release_create, +create_project_from_template) was implemented and pushed directly to main. -### Issues to implement (priority order) +This sprint fixes three remaining gaps found during code review on 2026-05-14. +These are blockers for `hyperguild new-project`. -**Batch 1 — blockers (do first, one PR: `feat/repo-crud`)** +### Issues to fix (all three in one PR: `fix/v02-patch`) -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #13 | `repo_create` | POST /api/v1/user/repos or /api/v1/orgs/{org}/repos | -| #16 | `repo_mirror_push` (add/list/delete) | POST/GET/DELETE /api/v1/repos/{owner}/{repo}/push_mirrors | -| #12 | `repo_update` | PATCH /api/v1/repos/{owner}/{repo} | +#### #12 — repo_update: add `archived` and `template` fields +**File:** `internal/gitea/repos.go` → `UpdateRepoArgs` struct +**File:** `internal/tools/repo_update.go` → input schema + args struct -**Batch 2 — quality of life (second PR: `feat/repo-ux`)** +Add to `UpdateRepoArgs`: +```go +Archived *bool +Template *bool +``` -| Issue | Tool | Gitea API | -|-------|------|-----------| -| #15 | `file_read` dir-path fix | existing endpoint, detect array vs object response | -| #14 | `repo_tree` | GET /api/v1/repos/{owner}/{repo}/git/trees/{sha}?recursive=true | -| #18 | `repo_topics_update` | PUT /api/v1/repos/{owner}/{repo}/topics | +Add to tool input schema: +```json +"archived": { + "type": "boolean", + "description": "Mark repo as archived (read-only). Requires confirm=." +}, +"template": { + "type": "boolean", + "description": "Toggle template repo flag." +} +``` -**Batch 3 — can wait** +Add confirm-guard for `archived=true` (same pattern as `private=false`): +```go +if args.Archived != nil && *args.Archived { + if args.Confirm != args.Name { + return nil, fmt.Errorf("setting archived=true is irreversible: set confirm=%q to proceed", args.Name) + } +} +``` -| Issue | Tool | Note | -|-------|------|------| -| #11 | `repo_delete` | HIGH risk — needs `confirm` param == repo name | -| #17 | `release_create` | POST /api/v1/repos/{owner}/{repo}/releases | +New test cases to add in `repo_update_test.go`: +- `TestRepoUpdateTool_Archive` — happy path with confirm +- `TestRepoUpdateTool_ArchiveRequiresConfirm` — missing confirm returns error +- `TestRepoUpdateTool_SetTemplate` — no confirm needed -### How to add a tool (pattern) +#### #24 — create_project_from_template: make template selectable +**File:** `internal/tools/create_project_from_template.go` -Every tool = 4 files following `internal/tools/repo_get.go` exactly: +Add optional `template_name` param to input schema: +```json +"template_name": { + "type": "string", + "enum": ["template-go-web", "template-go-agent"], + "description": "Template repo to generate from. Defaults to template-go-web.", + "default": "template-go-web" +} +``` -1. `internal/gitea/.go` — API client method (use PostJSON/PatchJSON/DeleteJSON) -2. `internal/tools/repo_.go` — tool handler with Descriptor() + Call() -3. `internal/tools/repo__test.go` — table-driven tests with httptest.NewServer -4. Registration in main — find where `NewRepoGet` is registered, add new tool same place +The tool should use `args.TemplateName` if set, fall back to the hardcoded default. +Remove the hardcoded template name from `cmd/gitea-mcp/main.go` constructor call — +the tool resolves it internally. -Key rules: -- Always call `t.a.Check(args.Owner)` before any API call (allowlist guard) -- Use `textOK(result)` for success output -- For `repo_mirror_push`: NEVER log or return `remote_password` in any output -- For `repo_update` with `private: false` and `repo_delete`: require `confirm` param == repo name +New test case: `TestCreateProjectFromTemplate_AgentTemplate` -### Token permissions needed +#### #25 — pr_files_diff: fix same diff returned for all files +**File:** `internal/tools/pr_files_diff.go` -New tools require these additional Gitea token scopes: -- `write:repository` — repo_create, repo_update, repo_mirror_push, repo_topics_update, release_create -- `delete_repo` — repo_delete +There is a loop bug where all file entries in the response contain the same diff +(the first file's diff is reused for every subsequent file). Find the loop and +ensure each iteration reads and assigns the correct diff for its own file. -Check current token: `curl -H "Authorization: token $GITEA_TOKEN" https://gitea.d-ma.be/api/v1/user` -If scopes are missing, update token in Gitea settings before running tests. +Reproduce: call `pr_files_diff` on any PR with 3+ files, verify each file has +a distinct diff. ### Definition of done -- `task check` passes (all tools, all batches) -- Each new tool manually callable via `claude mcp call` -- PR #1 (batch 1) merged before starting batch 2 -- Issue #19 (mirror flow e2e test) verified manually after batch 1 is deployed +- [ ] `task check` passes +- [ ] `repo_update` accepts `archived` and `template` params +- [ ] `archived=true` requires `confirm=` +- [ ] `create_project_from_template` accepts `template_name` param, defaults to `template-go-web` +- [ ] `pr_files_diff` returns distinct diff per file +- [ ] All new test cases pass +- [ ] PR `fix/v02-patch` merged to main via PR (not direct push) + +### After this sprint + +Next: `hyperguild new-project` v1 implementation. +See brain node `adr-new-project-gitea-first-github-mirror` for the full flow spec. +Also: verify end-to-end mirror flow (issue #19) once `repo_mirror_push` is confirmed working. -- 2.49.1