Files
ombi-mcp/internal/tools/issues.go
gronod 80e01253a2
Build and publish / Test and build (linux) (pull_request) Canceled after 0s
Build and publish / Test and build (windows) (pull_request) Canceled after 0s
Build and publish / Build and publish Docker image (pull_request) Canceled after 0s
Build and publish / Test and build (darwin) (pull_request) Canceled after 40s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m34s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 1m55s
Fix #10, #27 and #28 from the read-tools sweep
v2 request lists sent the RAML example sort field requestDate; Ombi
looks up RequestedDate and NullReferenceException'd every non-empty
page. Browse now streams TV popular/most-watched payloads and skips
the hydrated seasonRequests graph that blew the 8 MiB read budget.
provider_summary treats an empty upstream body as an empty group_page.
2026-09-19 20:59:57 +01:00

429 lines
12 KiB
Go

package tools
import (
"context"
"encoding/json"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// read_issues — paged v1 lists, v2 provider-grouped summaries, single
// records, request/provider lookups, comments and counts.
// Families: issue_page, comment_page, group_page, metrics.
func handleIssues(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_issues", false)
var a IssuesArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "list":
return o.issuesList(&a)
case "summary":
return o.issuesSummary(&a)
case "get":
return o.issuesGet(&a)
case "by_request":
return o.issuesByRequest(&a)
case "by_provider":
return o.issuesByProvider(&a)
case "provider_summary":
return o.issuesProviderSummary(&a)
case "comments":
return o.issuesComments(&a)
case "counts":
return o.issuesCounts()
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) issueStatusCode(a *IssuesArgs) (int, *ToolResult) {
c, err := translate.IssueStatusToWire(a.Status)
if err != nil {
return 0, o.invalid("status", "unsupported status %q", a.Status)
}
return c, nil
}
// issuesList uses v1 paged issues: take then skip (do not reverse).
func (o *op) issuesList(a *IssuesArgs) *ToolResult {
code, fail := o.issueStatusCode(a)
if fail != nil {
return fail
}
offset, limit := bounds(a.Page)
raw, fail := o.call("GET",
pathJoin("/api/v1/Issues", limit, offset, code), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Issue{}
for _, m := range arr {
items = append(items, o.projectIssue(m))
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), nil, "issues")
return o.ok(&IssuePage{Kind: "issue_page", Items: items, Page: pg})
}
// issuesSummary uses v2 provider groups: position then take.
func (o *op) issuesSummary(a *IssuesArgs) *ToolResult {
code, fail := o.issueStatusCode(a)
if fail != nil {
return fail
}
offset, limit := bounds(a.Page)
raw, fail := o.call("GET",
pathJoin("/api/v2/Issues", offset, limit, code), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Group{}
for _, m := range arr {
items = append(items, o.projectGroup(m))
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), nil, "provider_groups")
return o.ok(&GroupPage{Kind: "group_page", Items: items, Page: pg})
}
func (o *op) issuesGet(a *IssuesArgs) *ToolResult {
if a.IssueID == nil || *a.IssueID < 1 {
return o.invalid("issue_id", "issue_id must be a positive integer")
}
raw, fail := o.call("GET", "/api/v1/Issues/"+segInt(*a.IssueID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&IssuePage{Kind: "issue_page",
Items: []Issue{o.projectIssue(m)}, Page: singlePage(1, "issues")})
}
// issuesByArray projects an unspecified-body route that returns
// either a single issue object or an array of issues.
func (o *op) issuesByArray(path string) *ToolResult {
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
if arr, err := o.decodeArray(raw); err == nil {
items := []Issue{}
for _, m := range arr {
items = append(items, o.projectIssue(m))
}
items = capItems(o, items)
return o.ok(&IssuePage{Kind: "issue_page",
Items: items, Page: singlePage(len(items), "issues")})
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&IssuePage{Kind: "issue_page",
Items: []Issue{o.projectIssue(m)}, Page: singlePage(1, "issues")})
}
func (o *op) issuesByRequest(a *IssuesArgs) *ToolResult {
if a.RequestID == nil || *a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
return o.issuesByArray("/api/v1/Issues/request/" + segInt(*a.RequestID))
}
func (o *op) issuesByProvider(a *IssuesArgs) *ToolResult {
if !nonempty(a.ProviderID) {
return o.invalid("provider_id", "provider_id must be nonempty")
}
return o.issuesByArray("/api/v1/Issues/provider/" + seg(a.ProviderID))
}
func (o *op) issuesProviderSummary(a *IssuesArgs) *ToolResult {
if !nonempty(a.ProviderID) {
return o.invalid("provider_id", "provider_id must be nonempty")
}
raw, fail := o.call("GET",
"/api/v2/Issues/details/"+seg(a.ProviderID), nil, nil)
if fail != nil {
return fail
}
if emptyCollection(raw) {
return o.ok(&GroupPage{Kind: "group_page",
Items: []Group{}, Page: singlePage(0, "provider_groups")})
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
g := o.projectGroup(m)
return o.ok(&GroupPage{Kind: "group_page",
Items: []Group{g}, Page: singlePage(1, "provider_groups")})
}
func (o *op) issuesComments(a *IssuesArgs) *ToolResult {
if a.IssueID == nil || *a.IssueID < 1 {
return o.invalid("issue_id", "issue_id must be a positive integer")
}
raw, fail := o.call("GET",
"/api/v1/Issues/"+segInt(*a.IssueID)+"/comments", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Comment{}
for _, m := range arr {
items = append(items, projectComment(m))
}
win, pg := localWindow(o, items, a.Page, "comments")
return o.ok(&CommentPage{Kind: "comment_page", Items: win, Page: pg})
}
func (o *op) issuesCounts() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Issues/count", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
for _, k := range []string{"pending", "inProgress", "resolved"} {
if i, ok := toInt(m[k]); ok {
name := k
if k == "inProgress" {
name = "in_progress"
}
vals = append(vals, metric(name, i, "instance", "issues"))
}
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
// pathJoin appends integer path segments to base in order.
func pathJoin(base string, segs ...int) string {
out := base
for _, s := range segs {
out += "/" + segInt(s)
}
return out
}
// write_issue_create — minimal editable body; server supplies author,
// timestamps and state. At least one association is required by
// design (request_id or provider_id).
func handleIssueCreate(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_issue_create", true)
var a IssueCreateArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if !nonempty(a.Title) {
return o.invalid("title", "title must contain non-whitespace text")
}
if !nonempty(a.Description) {
return o.invalid("description", "description must contain non-whitespace text")
}
if a.CategoryID < 1 {
return o.invalid("category_id", "category_id must be a positive integer")
}
rt, err := translate.RequestTypeToWire(a.RequestType)
if err != nil {
return o.invalid("request_type", "unsupported request_type %q", a.RequestType)
}
if a.RequestID == nil && !nonempty(a.ProviderID) {
return o.invalid("request_id",
"at least one association is required: request_id or provider_id")
}
if a.RequestID != nil && *a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
body := ombi.IssueCreateBody{
Title: a.Title,
Subject: a.Subject,
Description: a.Description,
IssueCategoryID: a.CategoryID,
RequestType: rt,
RequestID: a.RequestID,
ProviderID: a.ProviderID,
}
raw2, fail := o.call("POST", "/api/v1/Issues", nil, body)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw2)
if fail != nil {
return fail
}
m := &Mutation{Kind: "mutation", Outcome: "completed"}
if id, ok := toInt(v); ok && id > 0 {
m.IssueID = &id
} else {
o.warnf("upstream returned no issue id")
m.Outcome = "accepted"
}
return o.ok(m)
}
// write_issue_comment — POST {comment, issueId}; returns the created
// comment entity.
func handleIssueComment(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_issue_comment", true)
var a IssueCommentArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if a.IssueID < 1 {
return o.invalid("issue_id", "issue_id must be a positive integer")
}
if !nonempty(a.Comment) {
return o.invalid("comment", "comment must contain non-whitespace text")
}
raw2, fail := o.call("POST", "/api/v1/Issues/comments", nil,
ombi.NewIssueCommentViewModel{Comment: a.Comment, IssueID: a.IssueID})
if fail != nil {
return fail
}
m := &Mutation{Kind: "mutation", Outcome: "completed", IssueID: &a.IssueID}
if obj, err := o.decodeObject(raw2); err == nil {
if id := jint(obj, "id"); id != nil {
m.CommentID = id
}
} else {
m.Outcome = "accepted"
o.warnf("upstream returned no comment entity")
}
return o.ok(m)
}
// write_issue_manage — status changes, deletes and category admin.
func handleIssueManage(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_issue_manage", true)
var a IssueManageArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "set_status":
return o.issueSetStatus(&a)
case "delete":
return o.issueDelete(&a)
case "delete_comment":
return o.issueDeleteComment(&a)
case "create_category":
return o.issueCreateCategory(&a)
case "delete_category":
return o.issueDeleteCategory(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) issueSetStatus(a *IssueManageArgs) *ToolResult {
if a.IssueID == nil || *a.IssueID < 1 {
return o.invalid("issue_id", "issue_id must be a positive integer")
}
st, err := translate.IssueStatusToWire(a.Status)
if err != nil {
return o.invalid("status", "unsupported status %q", a.Status)
}
raw, fail := o.call("POST", "/api/v1/Issues/status", nil,
ombi.IssueStateViewModel{IssueID: *a.IssueID, Status: st})
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, nil)
if fail != nil {
return fail
}
m.IssueID = a.IssueID
return o.ok(m)
}
func (o *op) issueDelete(a *IssueManageArgs) *ToolResult {
if a.IssueID == nil || *a.IssueID < 1 {
return o.invalid("issue_id", "issue_id must be a positive integer")
}
raw, fail := o.call("DELETE", "/api/v1/Issues/"+segInt(*a.IssueID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, nil)
if fail != nil {
return fail
}
m.IssueID = a.IssueID
return o.ok(m)
}
func (o *op) issueDeleteComment(a *IssueManageArgs) *ToolResult {
if a.CommentID == nil || *a.CommentID < 1 {
return o.invalid("comment_id", "comment_id must be a positive integer")
}
raw, fail := o.call("DELETE",
"/api/v1/Issues/comments/"+segInt(*a.CommentID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, nil)
if fail != nil {
return fail
}
m.CommentID = a.CommentID
return o.ok(m)
}
func (o *op) issueCreateCategory(a *IssueManageArgs) *ToolResult {
if !nonempty(a.Value) {
return o.invalid("value", "value must contain non-whitespace text")
}
raw, fail := o.call("POST", "/api/v1/Issues/categories", nil,
ombi.IssueCategoryBody{Value: a.Value})
if fail != nil {
return fail
}
return o.boolMutationOK(raw)
}
func (o *op) issueDeleteCategory(a *IssueManageArgs) *ToolResult {
if a.CategoryID == nil || *a.CategoryID < 1 {
return o.invalid("category_id", "category_id must be a positive integer")
}
raw, fail := o.call("DELETE",
"/api/v1/Issues/categories/"+segInt(*a.CategoryID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, nil)
if fail != nil {
return fail
}
m.CategoryID = a.CategoryID
return o.ok(m)
}
// boolMutationOK wraps a boolean write result with no extra fields.
func (o *op) boolMutationOK(raw []byte) *ToolResult {
m, fail := o.boolMutation(raw, nil)
if fail != nil {
return fail
}
return o.ok(m)
}