- errorlint: fmt.Errorf %v→%w across all handlers (errors now wrap the underlying SDK/HTTP error for errors.Is/As); errors.Is for context.Canceled in cmd - staticcheck quickfixes: Fprintf over WriteString(Sprintf), tagged switches, omit inferred 'any'; QF1001 resolved by hoisting hex checks into isHex predicates (normalizeColor, validateSHA) - govet: reflect.Ptr → reflect.Pointer in pkg/ptr - errcheck: explicit '_ =' for fs.Parse (ExitOnError) and deferred zap Sync - repair four test assertions the autofix broke (it dropped the 're.Code != X' half of combined type-assert checks, inverting the test); all errors.As sites use 'var re *resource.ResourceError' golangci-lint: 0 issues. staticcheck, vet, tests: clean.
430 lines
15 KiB
Go
430 lines
15 KiB
Go
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package issue
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"codeberg.org/goern/forgejo-mcp/v2/operation/params"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/ptr"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/to"
|
|
|
|
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
)
|
|
|
|
const (
|
|
CreateRepoLabelToolName = "create_repo_label"
|
|
EditRepoLabelToolName = "edit_repo_label"
|
|
DeleteRepoLabelToolName = "delete_repo_label"
|
|
GetRepoLabelToolName = "get_repo_label"
|
|
|
|
CreateOrgLabelToolName = "create_org_label"
|
|
EditOrgLabelToolName = "edit_org_label"
|
|
DeleteOrgLabelToolName = "delete_org_label"
|
|
GetOrgLabelToolName = "get_org_label"
|
|
)
|
|
|
|
var (
|
|
CreateRepoLabelTool = mcp.NewTool(
|
|
CreateRepoLabelToolName,
|
|
mcp.WithDescription("Create a repository label. Returns the created label including its numeric id."),
|
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
|
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
|
|
mcp.WithString("name", mcp.Required(), mcp.Description("Label name")),
|
|
mcp.WithString("color", mcp.Required(), mcp.Description("Label color as 6-digit hex (e.g. #0088ff or 0088ff)")),
|
|
mcp.WithString("description", mcp.Description("Label description")),
|
|
)
|
|
|
|
EditRepoLabelTool = mcp.NewTool(
|
|
EditRepoLabelToolName,
|
|
mcp.WithDescription("Edit a repository label (PATCH — only supplied fields change). Providing no fields is an error."),
|
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
|
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
|
|
mcp.WithNumber("id", mcp.Required(), mcp.Description("Label ID")),
|
|
mcp.WithString("name", mcp.Description("New label name")),
|
|
mcp.WithString("color", mcp.Description("New label color as 6-digit hex (e.g. #0088ff or 0088ff)")),
|
|
mcp.WithString("description", mcp.Description("New label description")),
|
|
)
|
|
|
|
DeleteRepoLabelTool = mcp.NewTool(
|
|
DeleteRepoLabelToolName,
|
|
mcp.WithDescription("Delete a repository label. By default refuses if the label is in use; set delete_mode=force to override."),
|
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
|
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
|
|
mcp.WithNumber("id", mcp.Required(), mcp.Description("Label ID")),
|
|
mcp.WithString("delete_mode", mcp.Description("safe (default): refuse if in use and report count. force: delete unconditionally.")),
|
|
)
|
|
|
|
GetRepoLabelTool = mcp.NewTool(
|
|
GetRepoLabelToolName,
|
|
mcp.WithDescription("Get a single repository label by ID."),
|
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
|
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
|
|
mcp.WithNumber("id", mcp.Required(), mcp.Description("Label ID")),
|
|
)
|
|
|
|
CreateOrgLabelTool = mcp.NewTool(
|
|
CreateOrgLabelToolName,
|
|
mcp.WithDescription("Create an organization-level label. Returns the created label including its numeric id."),
|
|
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
|
|
mcp.WithString("name", mcp.Required(), mcp.Description("Label name")),
|
|
mcp.WithString("color", mcp.Required(), mcp.Description("Label color as 6-digit hex (e.g. #0088ff or 0088ff)")),
|
|
mcp.WithString("description", mcp.Description("Label description")),
|
|
)
|
|
|
|
EditOrgLabelTool = mcp.NewTool(
|
|
EditOrgLabelToolName,
|
|
mcp.WithDescription("Edit an organization-level label (PATCH — only supplied fields change). Providing no fields is an error."),
|
|
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
|
|
mcp.WithNumber("id", mcp.Required(), mcp.Description("Label ID")),
|
|
mcp.WithString("name", mcp.Description("New label name")),
|
|
mcp.WithString("color", mcp.Description("New label color as 6-digit hex (e.g. #0088ff or 0088ff)")),
|
|
mcp.WithString("description", mcp.Description("New label description")),
|
|
)
|
|
|
|
DeleteOrgLabelTool = mcp.NewTool(
|
|
DeleteOrgLabelToolName,
|
|
mcp.WithDescription("Delete an organization-level label. By default refuses if the label is in use; set delete_mode=force to override. Note: in-use count is best-effort over repos visible to the token and may under-count."),
|
|
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
|
|
mcp.WithNumber("id", mcp.Required(), mcp.Description("Label ID")),
|
|
mcp.WithString("delete_mode", mcp.Description("safe (default): refuse if in use and report count. force: delete unconditionally.")),
|
|
)
|
|
|
|
GetOrgLabelTool = mcp.NewTool(
|
|
GetOrgLabelToolName,
|
|
mcp.WithDescription("Get a single organization-level label by ID."),
|
|
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
|
|
mcp.WithNumber("id", mcp.Required(), mcp.Description("Label ID")),
|
|
)
|
|
)
|
|
|
|
func RegisterLabelTool(s *server.MCPServer) {
|
|
s.AddTool(CreateRepoLabelTool, CreateRepoLabelFn)
|
|
s.AddTool(EditRepoLabelTool, EditRepoLabelFn)
|
|
s.AddTool(DeleteRepoLabelTool, DeleteRepoLabelFn)
|
|
s.AddTool(GetRepoLabelTool, GetRepoLabelFn)
|
|
s.AddTool(CreateOrgLabelTool, CreateOrgLabelFn)
|
|
s.AddTool(EditOrgLabelTool, EditOrgLabelFn)
|
|
s.AddTool(DeleteOrgLabelTool, DeleteOrgLabelFn)
|
|
s.AddTool(GetOrgLabelTool, GetOrgLabelFn)
|
|
}
|
|
|
|
// normalizeColor accepts rrggbb or #rrggbb (6-digit only), lowercases,
|
|
// prepends # if absent. Returns an error for invalid input.
|
|
// 3-digit shorthand is intentionally rejected (upstream behavior unverified).
|
|
func normalizeColor(color string) (string, error) {
|
|
c := strings.ToLower(strings.TrimSpace(color))
|
|
c = strings.TrimPrefix(c, "#")
|
|
if len(c) != 6 {
|
|
return "", fmt.Errorf("color must be a 6-digit hex string (e.g. #0088ff or 0088ff), got %q", color)
|
|
}
|
|
for _, ch := range c {
|
|
isHex := (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')
|
|
if !isHex {
|
|
return "", fmt.Errorf("color must be a 6-digit hex string (e.g. #0088ff or 0088ff), got %q", color)
|
|
}
|
|
}
|
|
return "#" + c, nil
|
|
}
|
|
|
|
// repoLabelInUseCount returns the number of issues/PRs in the repo that carry
|
|
// the label (identified by name). Uses X-Total-Count from the SDK *Response.
|
|
func repoLabelInUseCount(ctx context.Context, client *forgejo_sdk.Client, owner, repo, labelName string) (int, error) {
|
|
_, resp, err := client.ListRepoIssues(owner, repo, forgejo_sdk.ListIssueOption{
|
|
State: forgejo_sdk.StateType("all"),
|
|
Labels: []string{labelName},
|
|
ListOptions: forgejo_sdk.ListOptions{Page: 1, PageSize: 1},
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count label usage: %w", err)
|
|
}
|
|
if resp != nil {
|
|
if s := resp.Header.Get("X-Total-Count"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
return n, nil
|
|
}
|
|
}
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// orgLabelInUseCount returns the best-effort count of issues/PRs in the org's
|
|
// visible repos that carry the label (identified by name). May under-count for
|
|
// repos the token cannot read.
|
|
func orgLabelInUseCount(ctx context.Context, client *forgejo_sdk.Client, org, labelName string) (int, error) {
|
|
total := 0
|
|
page := 1
|
|
for {
|
|
repos, _, err := client.ListOrgRepos(org, forgejo_sdk.ListOrgReposOptions{
|
|
ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: 50},
|
|
})
|
|
if err != nil {
|
|
return total, fmt.Errorf("enumerate org repos for label usage count: %w", err)
|
|
}
|
|
if len(repos) == 0 {
|
|
break
|
|
}
|
|
for _, r := range repos {
|
|
n, _ := repoLabelInUseCount(ctx, client, org, r.Name, labelName)
|
|
total += n
|
|
}
|
|
page++
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// ---- Org label raw-HTTP types ----
|
|
|
|
type orgLabelOption struct {
|
|
Name string `json:"name,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
}
|
|
|
|
type orgLabelPatchOption struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Color *string `json:"color,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
}
|
|
|
|
// ---- Repo label handlers ----
|
|
|
|
func CreateRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called CreateRepoLabelFn")
|
|
owner, _ := req.GetArguments()["owner"].(string)
|
|
repo, _ := req.GetArguments()["repo"].(string)
|
|
name, _ := req.GetArguments()["name"].(string)
|
|
colorRaw, _ := req.GetArguments()["color"].(string)
|
|
description, _ := req.GetArguments()["description"].(string)
|
|
|
|
color, err := normalizeColor(colorRaw)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
opt := forgejo_sdk.CreateLabelOption{
|
|
Name: name,
|
|
Color: color,
|
|
Description: description,
|
|
}
|
|
label, _, err := client.CreateLabel(owner, repo, opt)
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("create repo label: %w", err))
|
|
}
|
|
return to.TextResult(label)
|
|
}
|
|
|
|
func EditRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called EditRepoLabelFn")
|
|
owner, _ := req.GetArguments()["owner"].(string)
|
|
repo, _ := req.GetArguments()["repo"].(string)
|
|
idF, _ := to.Float64(req.GetArguments()["id"])
|
|
|
|
nameRaw, nameSet := req.GetArguments()["name"].(string)
|
|
colorRaw, colorSet := req.GetArguments()["color"].(string)
|
|
descRaw, descSet := req.GetArguments()["description"].(string)
|
|
|
|
if !nameSet && !colorSet && !descSet {
|
|
return to.ErrorResult(fmt.Errorf("edit_repo_label: at least one of name, color, description must be provided"))
|
|
}
|
|
|
|
opt := forgejo_sdk.EditLabelOption{}
|
|
if nameSet && nameRaw != "" {
|
|
opt.Name = ptr.To(nameRaw)
|
|
}
|
|
if colorSet && colorRaw != "" {
|
|
color, err := normalizeColor(colorRaw)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
opt.Color = ptr.To(color)
|
|
}
|
|
if descSet {
|
|
opt.Description = ptr.To(descRaw)
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
label, _, err := client.EditLabel(owner, repo, int64(idF), opt)
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("edit repo label: %w", err))
|
|
}
|
|
return to.TextResult(label)
|
|
}
|
|
|
|
func DeleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called DeleteRepoLabelFn")
|
|
owner, _ := req.GetArguments()["owner"].(string)
|
|
repo, _ := req.GetArguments()["repo"].(string)
|
|
idF, _ := to.Float64(req.GetArguments()["id"])
|
|
deleteMode, _ := req.GetArguments()["delete_mode"].(string)
|
|
id := int64(idF)
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
if deleteMode != "force" {
|
|
label, _, err := client.GetRepoLabel(owner, repo, id)
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("get repo label: %w", err))
|
|
}
|
|
count, err := repoLabelInUseCount(ctx, client, owner, repo, label.Name)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
if count > 0 {
|
|
return to.ErrorResult(fmt.Errorf("label %q is used by %d issue(s)/PR(s); set delete_mode=force to delete anyway", label.Name, count))
|
|
}
|
|
}
|
|
|
|
resp, err := client.DeleteLabel(owner, repo, id)
|
|
if err != nil {
|
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
|
return to.ErrorResult(fmt.Errorf("label %d not found", id))
|
|
}
|
|
return to.ErrorResult(fmt.Errorf("delete repo label: %w", err))
|
|
}
|
|
return to.TextResult(map[string]any{"deleted": true, "id": id})
|
|
}
|
|
|
|
func GetRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called GetRepoLabelFn")
|
|
owner, _ := req.GetArguments()["owner"].(string)
|
|
repo, _ := req.GetArguments()["repo"].(string)
|
|
idF, _ := to.Float64(req.GetArguments()["id"])
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
label, _, err := client.GetRepoLabel(owner, repo, int64(idF))
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("get repo label: %w", err))
|
|
}
|
|
return to.TextResult(label)
|
|
}
|
|
|
|
// ---- Org label handlers ----
|
|
|
|
func CreateOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called CreateOrgLabelFn")
|
|
org, _ := req.GetArguments()["org"].(string)
|
|
name, _ := req.GetArguments()["name"].(string)
|
|
colorRaw, _ := req.GetArguments()["color"].(string)
|
|
description, _ := req.GetArguments()["description"].(string)
|
|
|
|
color, err := normalizeColor(colorRaw)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
body := orgLabelOption{Name: name, Color: color}
|
|
if description != "" {
|
|
body.Description = ptr.To(description)
|
|
}
|
|
|
|
var label forgejo_sdk.Label
|
|
if err := forgejo.DoJSON(ctx, http.MethodPost, fmt.Sprintf("/orgs/%s/labels", org), body, &label); err != nil {
|
|
return to.ErrorResult(fmt.Errorf("create org label: %w", err))
|
|
}
|
|
return to.TextResult(label)
|
|
}
|
|
|
|
func EditOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called EditOrgLabelFn")
|
|
org, _ := req.GetArguments()["org"].(string)
|
|
idF, _ := to.Float64(req.GetArguments()["id"])
|
|
|
|
nameRaw, nameSet := req.GetArguments()["name"].(string)
|
|
colorRaw, colorSet := req.GetArguments()["color"].(string)
|
|
descRaw, descSet := req.GetArguments()["description"].(string)
|
|
|
|
if !nameSet && !colorSet && !descSet {
|
|
return to.ErrorResult(fmt.Errorf("edit_org_label: at least one of name, color, description must be provided"))
|
|
}
|
|
|
|
opt := orgLabelPatchOption{}
|
|
if nameSet && nameRaw != "" {
|
|
opt.Name = ptr.To(nameRaw)
|
|
}
|
|
if colorSet && colorRaw != "" {
|
|
color, err := normalizeColor(colorRaw)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
opt.Color = ptr.To(color)
|
|
}
|
|
if descSet {
|
|
opt.Description = ptr.To(descRaw)
|
|
}
|
|
|
|
var label forgejo_sdk.Label
|
|
if err := forgejo.DoJSON(ctx, http.MethodPatch, fmt.Sprintf("/orgs/%s/labels/%d", org, int64(idF)), opt, &label); err != nil {
|
|
return to.ErrorResult(fmt.Errorf("edit org label: %w", err))
|
|
}
|
|
return to.TextResult(label)
|
|
}
|
|
|
|
func DeleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called DeleteOrgLabelFn")
|
|
org, _ := req.GetArguments()["org"].(string)
|
|
idF, _ := to.Float64(req.GetArguments()["id"])
|
|
deleteMode, _ := req.GetArguments()["delete_mode"].(string)
|
|
id := int64(idF)
|
|
|
|
if deleteMode != "force" {
|
|
var label forgejo_sdk.Label
|
|
if err := forgejo.DoJSON(ctx, http.MethodGet, fmt.Sprintf("/orgs/%s/labels/%d", org, id), nil, &label); err != nil {
|
|
return to.ErrorResult(fmt.Errorf("get org label: %w", err))
|
|
}
|
|
|
|
client, err := forgejo.Client(ctx)
|
|
if err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
count, countErr := orgLabelInUseCount(ctx, client, org, label.Name)
|
|
if countErr != nil {
|
|
return to.ErrorResult(fmt.Errorf("label %q in-use count failed (token may lack org-repo access); use delete_mode=force to override: %w", label.Name, countErr))
|
|
}
|
|
if count > 0 {
|
|
return to.ErrorResult(fmt.Errorf("label %q is used by %d issue(s)/PR(s) in visible repos (count excludes inaccessible repos); set delete_mode=force to delete anyway", label.Name, count))
|
|
}
|
|
}
|
|
|
|
if err := forgejo.DoJSON(ctx, http.MethodDelete, fmt.Sprintf("/orgs/%s/labels/%d", org, id), nil, nil); err != nil {
|
|
return to.ErrorResult(fmt.Errorf("delete org label: %w", err))
|
|
}
|
|
return to.TextResult(map[string]any{"deleted": true, "id": id})
|
|
}
|
|
|
|
func GetOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called GetOrgLabelFn")
|
|
org, _ := req.GetArguments()["org"].(string)
|
|
idF, _ := to.Float64(req.GetArguments()["id"])
|
|
|
|
var label forgejo_sdk.Label
|
|
if err := forgejo.DoJSON(ctx, http.MethodGet, fmt.Sprintf("/orgs/%s/labels/%d", org, int64(idF)), nil, &label); err != nil {
|
|
return to.ErrorResult(fmt.Errorf("get org label: %w", err))
|
|
}
|
|
return to.TextResult(label)
|
|
}
|