From 82823aad1e29e4aa7a8d8ec95a0455e007899771 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 4 Jul 2026 13:44:14 +0200 Subject: [PATCH] feat(labels): add label_list + issue_label tools (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #52 — unblocks parallax#3 dispatch labeling, which needs to both discover a repo's label set and attach labels to an issue by name (the CAD pipeline doesn't track Gitea's internal numeric label IDs). - gitea.Client: ListLabels, AddIssueLabels (additive POST, matches Gitea's own semantics — no delete-then-post needed); extend the existing Label struct with Color for label_list's output. - tools.LabelList (read-only, allowlisted): lists a repo's labels. - tools.IssueLabel (allowlisted): resolves label names to IDs via ListLabels, so callers pass names (the primary interface) instead of hunting for numeric IDs; also accepts label_ids for callers that already have them. An unknown name fails closed, naming exactly which label wasn't found. - Bump TestRegisteredToolCount 39 -> 41 in the same commit (this project was bitten today by a locked count going stale silently). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/gitea/issues.go | 5 +- internal/gitea/labels.go | 48 +++++++++++++ internal/gitea/labels_test.go | 107 +++++++++++++++++++++++++++++ internal/tools/issue_label.go | 87 +++++++++++++++++++++++ internal/tools/issue_label_test.go | 82 ++++++++++++++++++++++ internal/tools/label_list.go | 54 +++++++++++++++ internal/tools/label_list_test.go | 42 +++++++++++ internal/tools/registry.go | 2 + internal/tools/registry_test.go | 2 +- 9 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 internal/gitea/labels.go create mode 100644 internal/gitea/labels_test.go create mode 100644 internal/tools/issue_label.go create mode 100644 internal/tools/issue_label_test.go create mode 100644 internal/tools/label_list.go create mode 100644 internal/tools/label_list_test.go diff --git a/internal/gitea/issues.go b/internal/gitea/issues.go index fb58e54..868278f 100644 --- a/internal/gitea/issues.go +++ b/internal/gitea/issues.go @@ -22,8 +22,9 @@ type Issue struct { } type Label struct { - ID int64 `json:"id"` - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` } type User struct { diff --git a/internal/gitea/labels.go b/internal/gitea/labels.go new file mode 100644 index 0000000..7f8c278 --- /dev/null +++ b/internal/gitea/labels.go @@ -0,0 +1,48 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" +) + +// ListLabels fetches all labels defined on a repo. +func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var labels []Label + if err := json.Unmarshal(body, &labels); err != nil { + return nil, err + } + return labels, nil +} + +// AddIssueLabels adds labelIDs to an issue or pull request (PRs share index +// space with issues, per Gitea). This is additive per Gitea's own POST +// semantics — existing labels are left in place, no replace/delete needed. +// Returns the issue's full label set after the add. +func (c *Client) AddIssueLabels(ctx context.Context, owner, repo string, number int, labelIDs []int64) ([]Label, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, number) + payload, err := json.Marshal(map[string][]int64{"labels": labelIDs}) + if err != nil { + return nil, err + } + body, status, err := c.PostJSON(ctx, p, payload) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var labels []Label + if err := json.Unmarshal(body, &labels); err != nil { + return nil, err + } + return labels, nil +} diff --git a/internal/gitea/labels_test.go b/internal/gitea/labels_test.go new file mode 100644 index 0000000..04bbf06 --- /dev/null +++ b/internal/gitea/labels_test.go @@ -0,0 +1,107 @@ +package gitea_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListLabels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/o/r/labels", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":1,"name":"bug","color":"ee0701"}, + {"id":2,"name":"enhancement","color":"84b6eb"} + ]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + labels, err := c.ListLabels(context.Background(), "o", "r") + require.NoError(t, err) + require.Len(t, labels, 2) + assert.Equal(t, int64(1), labels[0].ID) + assert.Equal(t, "bug", labels[0].Name) + assert.Equal(t, "ee0701", labels[0].Color) + assert.Equal(t, "enhancement", labels[1].Name) +} + +func TestListLabels_Empty(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(`[]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + labels, err := c.ListLabels(context.Background(), "o", "r") + require.NoError(t, err) + assert.Empty(t, labels) +} + +func TestListLabels_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"repo not found"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + _, err := c.ListLabels(context.Background(), "o", "r") + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrNotFound) +} + +func TestAddIssueLabels(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/o/r/issues/42/labels", r.URL.Path) + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":1,"name":"bug","color":"ee0701"}, + {"id":3,"name":"priority","color":"00ff00"} + ]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + labels, err := c.AddIssueLabels(context.Background(), "o", "r", 42, []int64{3}) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + ids, ok := payload["labels"].([]any) + require.True(t, ok) + require.Len(t, ids, 1) + assert.Equal(t, float64(3), ids[0]) + + require.Len(t, labels, 2) + assert.Equal(t, "bug", labels[0].Name) + assert.Equal(t, "priority", labels[1].Name) +} + +func TestAddIssueLabels_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"issue not found"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + _, err := c.AddIssueLabels(context.Background(), "o", "r", 999, []int64{1}) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrNotFound) +} diff --git a/internal/tools/issue_label.go b/internal/tools/issue_label.go new file mode 100644 index 0000000..9ce6eaa --- /dev/null +++ b/internal/tools/issue_label.go @@ -0,0 +1,87 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" + "git.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type IssueLabel struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewIssueLabel(c *gitea.Client, a *allowlist.Allowlist) *IssueLabel { + return &IssueLabel{c: c, a: a} +} + +func (t *IssueLabel) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "issue_label", + Description: "Add labels to an issue or pull request. Resolves label names to IDs via the repo's label list. Additive — existing labels are left in place.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "repo":{"type":"string"}, + "number":{"type":"integer","minimum":1}, + "labels":{"type":"array","items":{"type":"string"}}, + "label_ids":{"type":"array","items":{"type":"integer"}} + }, + "required":["owner","repo","number","labels"] + }`), + } +} + +type issueLabelArgs struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + Labels []string `json:"labels,omitempty"` + LabelIDs []int64 `json:"label_ids,omitempty"` +} + +func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args issueLabelArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Number < 1 { + return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation) + } + if len(args.Labels) == 0 && len(args.LabelIDs) == 0 { + return nil, fmt.Errorf("labels is required: %w", gitea.ErrValidation) + } + + ids := append([]int64{}, args.LabelIDs...) + if len(args.Labels) > 0 { + existing, err := t.c.ListLabels(ctx, args.Owner, args.Repo) + if err != nil { + return nil, err + } + byName := make(map[string]int64, len(existing)) + for _, l := range existing { + byName[l.Name] = l.ID + } + for _, name := range args.Labels { + id, ok := byName[name] + if !ok { + return nil, fmt.Errorf("label %q not found in %s/%s: %w", name, args.Owner, args.Repo, gitea.ErrValidation) + } + ids = append(ids, id) + } + } + + labels, err := t.c.AddIssueLabels(ctx, args.Owner, args.Repo, args.Number, ids) + if err != nil { + return nil, err + } + return textOK(labels) +} diff --git a/internal/tools/issue_label_test.go b/internal/tools/issue_label_test.go new file mode 100644 index 0000000..1957262 --- /dev/null +++ b/internal/tools/issue_label_test.go @@ -0,0 +1,82 @@ +package tools_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" + "git.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const labelListFixture = `[ + {"id":1,"name":"bug","color":"ee0701"}, + {"id":2,"name":"enhancement","color":"84b6eb"} +]` + +func TestIssueLabelAppliesByName(t *testing.T) { + var captured []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/o/r/labels": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(labelListFixture)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/o/r/issues/42/labels": + var err error + captured, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(labelListFixture)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","enhancement"]}`)) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(captured, &payload)) + ids, ok := payload["labels"].([]any) + require.True(t, ok) + assert.ElementsMatch(t, []any{float64(1), float64(2)}, ids) + + assert.Contains(t, string(out), `"name":"bug"`) + assert.Contains(t, string(out), `"name":"enhancement"`) +} + +func TestIssueLabelUnknownNameNamesTheMissingLabel(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(labelListFixture)) + })) + defer srv.Close() + + tool := tools.NewIssueLabel(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":42,"labels":["bug","does-not-exist"]}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) + assert.Contains(t, err.Error(), `"does-not-exist"`) + assert.Contains(t, err.Error(), "o/r") +} + +func TestIssueLabelAllowlistRejects(t *testing.T) { + tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"r","number":1,"labels":["bug"]}`)) + require.Error(t, err) +} + +func TestIssueLabelRequiresValidNumber(t *testing.T) { + tool := tools.NewIssueLabel(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","repo":"r","number":0,"labels":["bug"]}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} diff --git a/internal/tools/label_list.go b/internal/tools/label_list.go new file mode 100644 index 0000000..704782f --- /dev/null +++ b/internal/tools/label_list.go @@ -0,0 +1,54 @@ +package tools + +import ( + "context" + "encoding/json" + + "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" + "git.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type LabelList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewLabelList(c *gitea.Client, a *allowlist.Allowlist) *LabelList { + return &LabelList{c: c, a: a} +} + +func (t *LabelList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "label_list", + Description: "List all labels defined on a repo. Returns id, name, and color for each label.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "repo":{"type":"string"} + }, + "required":["owner","repo"] + }`), + } +} + +type labelListArgs struct { + Owner string `json:"owner"` + Repo string `json:"repo"` +} + +func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args labelListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo) + if err != nil { + return nil, err + } + return textOK(labels) +} diff --git a/internal/tools/label_list_test.go b/internal/tools/label_list_test.go new file mode 100644 index 0000000..134929c --- /dev/null +++ b/internal/tools/label_list_test.go @@ -0,0 +1,42 @@ +package tools_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" + "git.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLabelListTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/labels", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":1,"name":"bug","color":"ee0701"}, + {"id":2,"name":"enhancement","color":"84b6eb"} + ]`)) + })) + defer srv.Close() + + tool := tools.NewLabelList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"id":1`) + assert.Contains(t, string(out), `"name":"bug"`) + assert.Contains(t, string(out), `"color":"ee0701"`) + assert.Contains(t, string(out), `"name":"enhancement"`) +} + +func TestLabelListAllowlistRejects(t *testing.T) { + tool := tools.NewLabelList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","repo":"x"}`)) + require.Error(t, err) +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index d6f60a1..cd0f16c 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -54,6 +54,8 @@ func RegisterAll( reg.Register(NewIssueListComments(c, a)) reg.Register(NewIssueClose(c, a)) reg.Register(NewIssueReopen(c, a)) + reg.Register(NewLabelList(c, a)) + reg.Register(NewIssueLabel(c, a)) reg.Register(NewWorkflowRunList(c, a)) reg.Register(NewReleaseCreate(c, a)) reg.Register(NewRepoDelete(c, a)) diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index e9987bf..c233c52 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -54,5 +54,5 @@ func TestEveryRegisteredToolIsDispatchable(t *testing.T) { // Lock the tool count so an accidental drop of a registration in RegisterAll // (the single source main.go and this test share) fails loudly. func TestRegisteredToolCount(t *testing.T) { - assert.Len(t, buildRegistry().Tools(), 39) + assert.Len(t, buildRegistry().Tools(), 41) }