From 8ed911eb27da3332e666c4b946bf0ef5d12b9807 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 15:12:33 +0000 Subject: [PATCH] feat(agentic): add issue and sprint platform surfaces Co-Authored-By: Virgil --- pkg/agentic/issue.go | 461 +++++++++++++++++++++++++++++++++ pkg/agentic/issue_test.go | 80 ++++++ pkg/agentic/plan_compat.go | 12 + pkg/agentic/platform_tools.go | 462 ++++++++++++++++++++++++++++++++++ pkg/agentic/prep.go | 15 ++ pkg/agentic/prep_test.go | 12 + pkg/agentic/sprint.go | 343 +++++++++++++++++++++++++ pkg/agentic/sprint_test.go | 76 ++++++ 8 files changed, 1461 insertions(+) create mode 100644 pkg/agentic/issue.go create mode 100644 pkg/agentic/issue_test.go create mode 100644 pkg/agentic/platform_tools.go create mode 100644 pkg/agentic/sprint.go create mode 100644 pkg/agentic/sprint_test.go diff --git a/pkg/agentic/issue.go b/pkg/agentic/issue.go new file mode 100644 index 0000000..519b16e --- /dev/null +++ b/pkg/agentic/issue.go @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// issue := agentic.Issue{Slug: "fix-auth", Title: "Fix auth middleware", Status: "open"} +type Issue struct { + ID int `json:"id"` + WorkspaceID int `json:"workspace_id,omitempty"` + SprintID int `json:"sprint_id,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Priority string `json:"priority,omitempty"` + Labels []string `json:"labels,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// comment := agentic.IssueComment{Author: "codex", Body: "Ready for review"} +type IssueComment struct { + ID int `json:"id"` + IssueID int `json:"issue_id,omitempty"` + Author string `json:"author"` + Body string `json:"body"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// input := agentic.IssueCreateInput{Title: "Fix auth", Type: "bug", Priority: "high"} +type IssueCreateInput struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Priority string `json:"priority,omitempty"` + Labels []string `json:"labels,omitempty"` + SprintID int `json:"sprint_id,omitempty"` + SprintSlug string `json:"sprint_slug,omitempty"` +} + +// input := agentic.IssueGetInput{Slug: "fix-auth"} +type IssueGetInput struct { + Slug string `json:"slug"` +} + +// input := agentic.IssueListInput{Status: "open", Type: "bug"} +type IssueListInput struct { + Status string `json:"status,omitempty"` + Type string `json:"type,omitempty"` + SprintID int `json:"sprint_id,omitempty"` + SprintSlug string `json:"sprint_slug,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// input := agentic.IssueUpdateInput{Slug: "fix-auth", Status: "in_progress"} +type IssueUpdateInput struct { + Slug string `json:"slug"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Priority string `json:"priority,omitempty"` + Labels []string `json:"labels,omitempty"` + SprintID int `json:"sprint_id,omitempty"` + SprintSlug string `json:"sprint_slug,omitempty"` +} + +// input := agentic.IssueCommentInput{Slug: "fix-auth", Body: "Ready for review"} +type IssueCommentInput struct { + Slug string `json:"slug"` + Body string `json:"body"` + Author string `json:"author,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// input := agentic.IssueArchiveInput{Slug: "fix-auth"} +type IssueArchiveInput struct { + Slug string `json:"slug"` +} + +// out := agentic.IssueOutput{Success: true, Issue: agentic.Issue{Slug: "fix-auth"}} +type IssueOutput struct { + Success bool `json:"success"` + Issue Issue `json:"issue"` +} + +// out := agentic.IssueListOutput{Success: true, Count: 1, Issues: []agentic.Issue{{Slug: "fix-auth"}}} +type IssueListOutput struct { + Success bool `json:"success"` + Count int `json:"count"` + Issues []Issue `json:"issues"` +} + +// out := agentic.IssueCommentOutput{Success: true, Comment: agentic.IssueComment{Author: "codex"}} +type IssueCommentOutput struct { + Success bool `json:"success"` + Comment IssueComment `json:"comment"` +} + +// out := agentic.IssueArchiveOutput{Success: true, Archived: "fix-auth"} +type IssueArchiveOutput struct { + Success bool `json:"success"` + Archived string `json:"archived"` +} + +// result := c.Action("issue.create").Run(ctx, core.NewOptions(core.Option{Key: "title", Value: "Fix auth"})) +func (s *PrepSubsystem) handleIssueRecordCreate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueCreate(ctx, nil, IssueCreateInput{ + Title: optionStringValue(options, "title"), + Description: optionStringValue(options, "description"), + Type: optionStringValue(options, "type"), + Status: optionStringValue(options, "status"), + Priority: optionStringValue(options, "priority"), + Labels: optionStringSliceValue(options, "labels"), + SprintID: optionIntValue(options, "sprint_id", "sprint-id"), + SprintSlug: optionStringValue(options, "sprint_slug", "sprint-slug"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("issue.get").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) +func (s *PrepSubsystem) handleIssueRecordGet(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueGet(ctx, nil, IssueGetInput{ + Slug: optionStringValue(options, "slug", "_arg"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("issue.list").Run(ctx, core.NewOptions(core.Option{Key: "status", Value: "open"})) +func (s *PrepSubsystem) handleIssueRecordList(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueList(ctx, nil, IssueListInput{ + Status: optionStringValue(options, "status"), + Type: optionStringValue(options, "type"), + SprintID: optionIntValue(options, "sprint_id", "sprint-id"), + SprintSlug: optionStringValue(options, "sprint_slug", "sprint-slug"), + Limit: optionIntValue(options, "limit"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("issue.update").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) +func (s *PrepSubsystem) handleIssueRecordUpdate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueUpdate(ctx, nil, IssueUpdateInput{ + Slug: optionStringValue(options, "slug", "_arg"), + Title: optionStringValue(options, "title"), + Description: optionStringValue(options, "description"), + Type: optionStringValue(options, "type"), + Status: optionStringValue(options, "status"), + Priority: optionStringValue(options, "priority"), + Labels: optionStringSliceValue(options, "labels"), + SprintID: optionIntValue(options, "sprint_id", "sprint-id"), + SprintSlug: optionStringValue(options, "sprint_slug", "sprint-slug"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("issue.comment").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) +func (s *PrepSubsystem) handleIssueRecordComment(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueComment(ctx, nil, IssueCommentInput{ + Slug: optionStringValue(options, "slug", "_arg"), + Body: optionStringValue(options, "body"), + Author: optionStringValue(options, "author"), + Metadata: optionAnyMapValue(options, "metadata"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("issue.archive").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) +func (s *PrepSubsystem) handleIssueRecordArchive(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueArchive(ctx, nil, IssueArchiveInput{ + Slug: optionStringValue(options, "slug", "_arg"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerIssueTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_create", + Description: "Create a tracked platform issue with title, type, priority, labels, and optional sprint assignment.", + }, s.issueCreate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_get", + Description: "Read a tracked platform issue by slug.", + }, s.issueGet) + + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_list", + Description: "List tracked platform issues with optional status, type, sprint, and limit filters.", + }, s.issueList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_update", + Description: "Update fields on a tracked platform issue by slug.", + }, s.issueUpdate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_comment", + Description: "Add a comment to a tracked platform issue.", + }, s.issueComment) + + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_archive", + Description: "Archive a tracked platform issue by slug.", + }, s.issueArchive) +} + +func (s *PrepSubsystem) issueCreate(ctx context.Context, _ *mcp.CallToolRequest, input IssueCreateInput) (*mcp.CallToolResult, IssueOutput, error) { + if input.Title == "" { + return nil, IssueOutput{}, core.E("issueCreate", "title is required", nil) + } + + body := map[string]any{ + "title": input.Title, + } + if input.Description != "" { + body["description"] = input.Description + } + if input.Type != "" { + body["type"] = input.Type + } + if input.Status != "" { + body["status"] = input.Status + } + if input.Priority != "" { + body["priority"] = input.Priority + } + if len(input.Labels) > 0 { + body["labels"] = input.Labels + } + if input.SprintID > 0 { + body["sprint_id"] = input.SprintID + } + if input.SprintSlug != "" { + body["sprint_slug"] = input.SprintSlug + } + + result := s.platformPayload(ctx, "issue.create", "POST", "/v1/issues", body) + if !result.OK { + return nil, IssueOutput{}, resultErrorValue("issue.create", result) + } + + return nil, IssueOutput{ + Success: true, + Issue: parseIssue(payloadResourceMap(result.Value.(map[string]any), "issue")), + }, nil +} + +func (s *PrepSubsystem) issueGet(ctx context.Context, _ *mcp.CallToolRequest, input IssueGetInput) (*mcp.CallToolResult, IssueOutput, error) { + if input.Slug == "" { + return nil, IssueOutput{}, core.E("issueGet", "slug is required", nil) + } + + result := s.platformPayload(ctx, "issue.get", "GET", core.Concat("/v1/issues/", input.Slug), nil) + if !result.OK { + return nil, IssueOutput{}, resultErrorValue("issue.get", result) + } + + return nil, IssueOutput{ + Success: true, + Issue: parseIssue(payloadResourceMap(result.Value.(map[string]any), "issue")), + }, nil +} + +func (s *PrepSubsystem) issueList(ctx context.Context, _ *mcp.CallToolRequest, input IssueListInput) (*mcp.CallToolResult, IssueListOutput, error) { + path := "/v1/issues" + path = appendQueryParam(path, "status", input.Status) + path = appendQueryParam(path, "type", input.Type) + if input.SprintID > 0 { + path = appendQueryParam(path, "sprint_id", core.Sprint(input.SprintID)) + } + path = appendQueryParam(path, "sprint_slug", input.SprintSlug) + if input.Limit > 0 { + path = appendQueryParam(path, "limit", core.Sprint(input.Limit)) + } + + result := s.platformPayload(ctx, "issue.list", "GET", path, nil) + if !result.OK { + return nil, IssueListOutput{}, resultErrorValue("issue.list", result) + } + + return nil, parseIssueListOutput(result.Value.(map[string]any)), nil +} + +func (s *PrepSubsystem) issueUpdate(ctx context.Context, _ *mcp.CallToolRequest, input IssueUpdateInput) (*mcp.CallToolResult, IssueOutput, error) { + if input.Slug == "" { + return nil, IssueOutput{}, core.E("issueUpdate", "slug is required", nil) + } + + body := map[string]any{} + if input.Title != "" { + body["title"] = input.Title + } + if input.Description != "" { + body["description"] = input.Description + } + if input.Type != "" { + body["type"] = input.Type + } + if input.Status != "" { + body["status"] = input.Status + } + if input.Priority != "" { + body["priority"] = input.Priority + } + if len(input.Labels) > 0 { + body["labels"] = input.Labels + } + if input.SprintID > 0 { + body["sprint_id"] = input.SprintID + } + if input.SprintSlug != "" { + body["sprint_slug"] = input.SprintSlug + } + if len(body) == 0 { + return nil, IssueOutput{}, core.E("issueUpdate", "at least one field is required", nil) + } + + result := s.platformPayload(ctx, "issue.update", "PATCH", core.Concat("/v1/issues/", input.Slug), body) + if !result.OK { + return nil, IssueOutput{}, resultErrorValue("issue.update", result) + } + + return nil, IssueOutput{ + Success: true, + Issue: parseIssue(payloadResourceMap(result.Value.(map[string]any), "issue")), + }, nil +} + +func (s *PrepSubsystem) issueComment(ctx context.Context, _ *mcp.CallToolRequest, input IssueCommentInput) (*mcp.CallToolResult, IssueCommentOutput, error) { + if input.Slug == "" { + return nil, IssueCommentOutput{}, core.E("issueComment", "slug is required", nil) + } + if input.Body == "" { + return nil, IssueCommentOutput{}, core.E("issueComment", "body is required", nil) + } + + body := map[string]any{ + "body": input.Body, + } + if input.Author != "" { + body["author"] = input.Author + } + if len(input.Metadata) > 0 { + body["metadata"] = input.Metadata + } + + result := s.platformPayload(ctx, "issue.comment", "POST", core.Concat("/v1/issues/", input.Slug, "/comments"), body) + if !result.OK { + return nil, IssueCommentOutput{}, resultErrorValue("issue.comment", result) + } + + return nil, IssueCommentOutput{ + Success: true, + Comment: parseIssueComment(payloadResourceMap(result.Value.(map[string]any), "comment")), + }, nil +} + +func (s *PrepSubsystem) issueArchive(ctx context.Context, _ *mcp.CallToolRequest, input IssueArchiveInput) (*mcp.CallToolResult, IssueArchiveOutput, error) { + if input.Slug == "" { + return nil, IssueArchiveOutput{}, core.E("issueArchive", "slug is required", nil) + } + + result := s.platformPayload(ctx, "issue.archive", "DELETE", core.Concat("/v1/issues/", input.Slug), nil) + if !result.OK { + return nil, IssueArchiveOutput{}, resultErrorValue("issue.archive", result) + } + + output := IssueArchiveOutput{ + Success: true, + Archived: input.Slug, + } + if values := payloadResourceMap(result.Value.(map[string]any), "issue", "result"); len(values) > 0 { + if slug := stringValue(values["slug"]); slug != "" { + output.Archived = slug + } + if value, ok := boolValueOK(values["success"]); ok { + output.Success = value + } + } + return nil, output, nil +} + +func parseIssue(values map[string]any) Issue { + return Issue{ + ID: intValue(values["id"]), + WorkspaceID: intValue(values["workspace_id"]), + SprintID: intValue(values["sprint_id"]), + Slug: stringValue(values["slug"]), + Title: stringValue(values["title"]), + Description: stringValue(values["description"]), + Type: stringValue(values["type"]), + Status: stringValue(values["status"]), + Priority: stringValue(values["priority"]), + Labels: listValue(values["labels"]), + Metadata: anyMapValue(values["metadata"]), + CreatedAt: stringValue(values["created_at"]), + UpdatedAt: stringValue(values["updated_at"]), + } +} + +func parseIssueComment(values map[string]any) IssueComment { + return IssueComment{ + ID: intValue(values["id"]), + IssueID: intValue(values["issue_id"]), + Author: stringValue(values["author"]), + Body: stringValue(values["body"]), + Metadata: anyMapValue(values["metadata"]), + CreatedAt: stringValue(values["created_at"]), + } +} + +func parseIssueListOutput(payload map[string]any) IssueListOutput { + issuesData := payloadDataSlice(payload, "issues") + issues := make([]Issue, 0, len(issuesData)) + for _, values := range issuesData { + issues = append(issues, parseIssue(values)) + } + + count := mapIntValue(payload, "total", "count") + if count == 0 { + count = mapIntValue(payloadDataMap(payload), "total", "count") + } + if count == 0 { + count = len(issues) + } + + return IssueListOutput{ + Success: true, + Count: count, + Issues: issues, + } +} diff --git a/pkg/agentic/issue_test.go b/pkg/agentic/issue_test.go new file mode 100644 index 0000000..5156ff5 --- /dev/null +++ b/pkg/agentic/issue_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssue_HandleIssueRecordCreate_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/issues", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "Fix auth", payload["title"]) + require.Equal(t, "bug", payload["type"]) + + _, _ = w.Write([]byte(`{"data":{"slug":"fix-auth","title":"Fix auth","type":"bug","status":"open","priority":"high","labels":["auth"]}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleIssueRecordCreate(context.Background(), core.NewOptions( + core.Option{Key: "title", Value: "Fix auth"}, + core.Option{Key: "type", Value: "bug"}, + core.Option{Key: "priority", Value: "high"}, + core.Option{Key: "labels", Value: "auth"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(IssueOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "fix-auth", output.Issue.Slug) + assert.Equal(t, "open", output.Issue.Status) + assert.Equal(t, []string{"auth"}, output.Issue.Labels) +} + +func TestIssue_HandleIssueRecordGet_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleIssueRecordGet(context.Background(), core.NewOptions()) + assert.False(t, result.OK) + assert.EqualError(t, result.Value.(error), "issueGet: slug is required") +} + +func TestIssue_HandleIssueRecordList_Ugly_NestedEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/issues", r.URL.Path) + require.Equal(t, "open", r.URL.Query().Get("status")) + _, _ = w.Write([]byte(`{"data":{"issues":[{"id":7,"workspace_id":3,"sprint_id":5,"slug":"fix-auth","title":"Fix auth","labels":["auth","backend"]}],"total":1}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleIssueRecordList(context.Background(), core.NewOptions( + core.Option{Key: "status", Value: "open"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(IssueListOutput) + require.True(t, ok) + require.Len(t, output.Issues, 1) + assert.Equal(t, 1, output.Count) + assert.Equal(t, 3, output.Issues[0].WorkspaceID) + assert.Equal(t, 5, output.Issues[0].SprintID) + assert.Equal(t, []string{"auth", "backend"}, output.Issues[0].Labels) +} diff --git a/pkg/agentic/plan_compat.go b/pkg/agentic/plan_compat.go index 4899f1a..404f683 100644 --- a/pkg/agentic/plan_compat.go +++ b/pkg/agentic/plan_compat.go @@ -84,6 +84,18 @@ func (s *PrepSubsystem) handlePlanArchive(ctx context.Context, options core.Opti return core.Result{Value: output, OK: true} } +// result := c.Action("plan.update_status").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "my-plan-abc123"})) +func (s *PrepSubsystem) handlePlanUpdateStatus(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planUpdateStatusCompat(ctx, nil, PlanStatusUpdateInput{ + Slug: optionStringValue(options, "slug", "_arg"), + Status: optionStringValue(options, "status"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) planCreateCompat(ctx context.Context, _ *mcp.CallToolRequest, input PlanCreateInput) (*mcp.CallToolResult, PlanCompatibilityCreateOutput, error) { _, created, err := s.planCreate(ctx, nil, input) if err != nil { diff --git a/pkg/agentic/platform_tools.go b/pkg/agentic/platform_tools.go new file mode 100644 index 0000000..796101d --- /dev/null +++ b/pkg/agentic/platform_tools.go @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// input := agentic.SyncStatusInput{AgentID: "charon"} +type SyncStatusInput struct { + AgentID string `json:"agent_id,omitempty"` +} + +// input := agentic.FleetDeregisterInput{AgentID: "charon"} +type FleetDeregisterInput struct { + AgentID string `json:"agent_id"` +} + +// input := agentic.FleetTaskAssignInput{AgentID: "charon", Repo: "core/go-io", Task: "Fix tests"} +type FleetTaskAssignInput struct { + AgentID string `json:"agent_id"` + Repo string `json:"repo"` + Branch string `json:"branch,omitempty"` + Task string `json:"task"` + Template string `json:"template,omitempty"` + AgentModel string `json:"agent_model,omitempty"` +} + +// input := agentic.FleetTaskCompleteInput{AgentID: "charon", TaskID: 7} +type FleetTaskCompleteInput struct { + AgentID string `json:"agent_id"` + TaskID int `json:"task_id"` + Result map[string]any `json:"result,omitempty"` + Findings []map[string]any `json:"findings,omitempty"` + Changes map[string]any `json:"changes,omitempty"` + Report map[string]any `json:"report,omitempty"` +} + +func (s *PrepSubsystem) registerPlatformTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_sync_push", + Description: "Push completed dispatch state to the platform API for fleet-wide context sharing.", + }, s.syncPushTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_sync_pull", + Description: "Pull fleet-wide context from the platform API into the local cache.", + }, s.syncPullTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_sync_status", + Description: "Read platform sync status for an agent, including queued items and last push/pull times.", + }, s.syncStatusTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_auth_provision", + Description: "Provision a platform API key for an authenticated agent user.", + }, s.authProvisionTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_auth_revoke", + Description: "Revoke a platform API key by key ID.", + }, s.authRevokeTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_register", + Description: "Register a fleet node with models, capabilities, and platform metadata.", + }, s.fleetRegisterTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_heartbeat", + Description: "Send a fleet heartbeat update with status and optional compute budget.", + }, s.fleetHeartbeatTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_deregister", + Description: "Deregister a fleet node from the platform API.", + }, s.fleetDeregisterTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_nodes", + Description: "List registered fleet nodes with optional status and platform filters.", + }, s.fleetNodesTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_task_assign", + Description: "Assign a task to a fleet node.", + }, s.fleetTaskAssignTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_task_complete", + Description: "Complete a fleet task and report result, findings, changes, and report data.", + }, s.fleetTaskCompleteTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_task_next", + Description: "Ask the platform for the next available fleet task for an agent.", + }, s.fleetTaskNextTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_fleet_stats", + Description: "Read aggregate fleet activity statistics.", + }, s.fleetStatsTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_credits_award", + Description: "Award credits to a fleet node for completed work.", + }, s.creditsAwardTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_credits_balance", + Description: "Read the current credit balance for a fleet node.", + }, s.creditsBalanceTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_credits_history", + Description: "List credit history entries for a fleet node.", + }, s.creditsHistoryTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_subscription_detect", + Description: "Detect provider capabilities available to a fleet node.", + }, s.subscriptionDetectTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_subscription_budget", + Description: "Read the current compute budget for a fleet node.", + }, s.subscriptionBudgetTool) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_subscription_budget_update", + Description: "Update the compute budget limits for a fleet node.", + }, s.subscriptionBudgetUpdateTool) +} + +func (s *PrepSubsystem) syncPushTool(ctx context.Context, _ *mcp.CallToolRequest, input SyncPushInput) (*mcp.CallToolResult, SyncPushOutput, error) { + output, err := s.syncPush(ctx, input.AgentID) + if err != nil { + return nil, SyncPushOutput{}, err + } + return nil, output, nil +} + +func (s *PrepSubsystem) syncPullTool(ctx context.Context, _ *mcp.CallToolRequest, input SyncPullInput) (*mcp.CallToolResult, SyncPullOutput, error) { + output, err := s.syncPull(ctx, input.AgentID) + if err != nil { + return nil, SyncPullOutput{}, err + } + return nil, output, nil +} + +func (s *PrepSubsystem) syncStatusTool(ctx context.Context, _ *mcp.CallToolRequest, input SyncStatusInput) (*mcp.CallToolResult, SyncStatusOutput, error) { + result := s.handleSyncStatus(ctx, platformOptions(core.Option{Key: "agent_id", Value: input.AgentID})) + if !result.OK { + return nil, SyncStatusOutput{}, resultErrorValue("agentic.sync.status", result) + } + output, ok := result.Value.(SyncStatusOutput) + if !ok { + return nil, SyncStatusOutput{}, core.E("agentic.sync.status", "invalid sync status output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) authProvisionTool(ctx context.Context, _ *mcp.CallToolRequest, input AuthProvisionInput) (*mcp.CallToolResult, AuthProvisionOutput, error) { + options := platformOptions( + core.Option{Key: "oauth_user_id", Value: input.OAuthUserID}, + core.Option{Key: "name", Value: input.Name}, + core.Option{Key: "permissions", Value: input.Permissions}, + core.Option{Key: "rate_limit", Value: input.RateLimit}, + core.Option{Key: "expires_at", Value: input.ExpiresAt}, + ) + result := s.handleAuthProvision(ctx, options) + if !result.OK { + return nil, AuthProvisionOutput{}, resultErrorValue("agentic.auth.provision", result) + } + output, ok := result.Value.(AuthProvisionOutput) + if !ok { + return nil, AuthProvisionOutput{}, core.E("agentic.auth.provision", "invalid auth provision output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) authRevokeTool(ctx context.Context, _ *mcp.CallToolRequest, input AuthRevokeInput) (*mcp.CallToolResult, AuthRevokeOutput, error) { + result := s.handleAuthRevoke(ctx, platformOptions(core.Option{Key: "key_id", Value: input.KeyID})) + if !result.OK { + return nil, AuthRevokeOutput{}, resultErrorValue("agentic.auth.revoke", result) + } + output, ok := result.Value.(AuthRevokeOutput) + if !ok { + return nil, AuthRevokeOutput{}, core.E("agentic.auth.revoke", "invalid auth revoke output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetRegisterTool(ctx context.Context, _ *mcp.CallToolRequest, input FleetNode) (*mcp.CallToolResult, FleetNode, error) { + options := platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "platform", Value: input.Platform}, + core.Option{Key: "models", Value: input.Models}, + core.Option{Key: "capabilities", Value: input.Capabilities}, + ) + result := s.handleFleetRegister(ctx, options) + if !result.OK { + return nil, FleetNode{}, resultErrorValue("agentic.fleet.register", result) + } + output, ok := result.Value.(FleetNode) + if !ok { + return nil, FleetNode{}, core.E("agentic.fleet.register", "invalid fleet register output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetHeartbeatTool(ctx context.Context, _ *mcp.CallToolRequest, input FleetNode) (*mcp.CallToolResult, FleetNode, error) { + options := platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "status", Value: input.Status}, + core.Option{Key: "compute_budget", Value: input.ComputeBudget}, + ) + result := s.handleFleetHeartbeat(ctx, options) + if !result.OK { + return nil, FleetNode{}, resultErrorValue("agentic.fleet.heartbeat", result) + } + output, ok := result.Value.(FleetNode) + if !ok { + return nil, FleetNode{}, core.E("agentic.fleet.heartbeat", "invalid fleet heartbeat output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetDeregisterTool(ctx context.Context, _ *mcp.CallToolRequest, input FleetDeregisterInput) (*mcp.CallToolResult, map[string]any, error) { + result := s.handleFleetDeregister(ctx, platformOptions(core.Option{Key: "agent_id", Value: input.AgentID})) + if !result.OK { + return nil, nil, resultErrorValue("agentic.fleet.deregister", result) + } + output, ok := result.Value.(map[string]any) + if !ok { + return nil, nil, core.E("agentic.fleet.deregister", "invalid fleet deregister output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetNodesTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + Status string `json:"status,omitempty"` + Platform string `json:"platform,omitempty"` +}) (*mcp.CallToolResult, FleetNodesOutput, error) { + result := s.handleFleetNodes(ctx, platformOptions( + core.Option{Key: "status", Value: input.Status}, + core.Option{Key: "platform", Value: input.Platform}, + )) + if !result.OK { + return nil, FleetNodesOutput{}, resultErrorValue("agentic.fleet.nodes", result) + } + output, ok := result.Value.(FleetNodesOutput) + if !ok { + return nil, FleetNodesOutput{}, core.E("agentic.fleet.nodes", "invalid fleet nodes output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetTaskAssignTool(ctx context.Context, _ *mcp.CallToolRequest, input FleetTaskAssignInput) (*mcp.CallToolResult, FleetTask, error) { + options := platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "repo", Value: input.Repo}, + core.Option{Key: "branch", Value: input.Branch}, + core.Option{Key: "task", Value: input.Task}, + core.Option{Key: "template", Value: input.Template}, + core.Option{Key: "agent_model", Value: input.AgentModel}, + ) + result := s.handleFleetAssignTask(ctx, options) + if !result.OK { + return nil, FleetTask{}, resultErrorValue("agentic.fleet.task.assign", result) + } + output, ok := result.Value.(FleetTask) + if !ok { + return nil, FleetTask{}, core.E("agentic.fleet.task.assign", "invalid fleet task output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetTaskCompleteTool(ctx context.Context, _ *mcp.CallToolRequest, input FleetTaskCompleteInput) (*mcp.CallToolResult, FleetTask, error) { + result := s.handleFleetCompleteTask(ctx, platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "task_id", Value: input.TaskID}, + core.Option{Key: "result", Value: input.Result}, + core.Option{Key: "findings", Value: input.Findings}, + core.Option{Key: "changes", Value: input.Changes}, + core.Option{Key: "report", Value: input.Report}, + )) + if !result.OK { + return nil, FleetTask{}, resultErrorValue("agentic.fleet.task.complete", result) + } + output, ok := result.Value.(FleetTask) + if !ok { + return nil, FleetTask{}, core.E("agentic.fleet.task.complete", "invalid fleet task output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetTaskNextTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + AgentID string `json:"agent_id"` + Capabilities []string `json:"capabilities,omitempty"` +}) (*mcp.CallToolResult, *FleetTask, error) { + result := s.handleFleetNextTask(ctx, platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "capabilities", Value: input.Capabilities}, + )) + if !result.OK { + return nil, nil, resultErrorValue("agentic.fleet.task.next", result) + } + output, ok := result.Value.(*FleetTask) + if !ok { + return nil, nil, core.E("agentic.fleet.task.next", "invalid fleet next-task output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) fleetStatsTool(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, FleetStats, error) { + result := s.handleFleetStats(ctx, core.NewOptions()) + if !result.OK { + return nil, FleetStats{}, resultErrorValue("agentic.fleet.stats", result) + } + output, ok := result.Value.(FleetStats) + if !ok { + return nil, FleetStats{}, core.E("agentic.fleet.stats", "invalid fleet stats output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) creditsAwardTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + AgentID string `json:"agent_id"` + TaskType string `json:"task_type"` + Amount int `json:"amount"` + FleetNodeID int `json:"fleet_node_id,omitempty"` + Description string `json:"description,omitempty"` +}) (*mcp.CallToolResult, CreditEntry, error) { + result := s.handleCreditsAward(ctx, platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "task_type", Value: input.TaskType}, + core.Option{Key: "amount", Value: input.Amount}, + core.Option{Key: "fleet_node_id", Value: input.FleetNodeID}, + core.Option{Key: "description", Value: input.Description}, + )) + if !result.OK { + return nil, CreditEntry{}, resultErrorValue("agentic.credits.award", result) + } + output, ok := result.Value.(CreditEntry) + if !ok { + return nil, CreditEntry{}, core.E("agentic.credits.award", "invalid credit award output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) creditsBalanceTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + AgentID string `json:"agent_id"` +}) (*mcp.CallToolResult, CreditBalance, error) { + result := s.handleCreditsBalance(ctx, platformOptions(core.Option{Key: "agent_id", Value: input.AgentID})) + if !result.OK { + return nil, CreditBalance{}, resultErrorValue("agentic.credits.balance", result) + } + output, ok := result.Value.(CreditBalance) + if !ok { + return nil, CreditBalance{}, core.E("agentic.credits.balance", "invalid credit balance output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) creditsHistoryTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + AgentID string `json:"agent_id"` + Limit int `json:"limit,omitempty"` +}) (*mcp.CallToolResult, CreditsHistoryOutput, error) { + result := s.handleCreditsHistory(ctx, platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "limit", Value: input.Limit}, + )) + if !result.OK { + return nil, CreditsHistoryOutput{}, resultErrorValue("agentic.credits.history", result) + } + output, ok := result.Value.(CreditsHistoryOutput) + if !ok { + return nil, CreditsHistoryOutput{}, core.E("agentic.credits.history", "invalid credit history output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) subscriptionDetectTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + APIKeys map[string]string `json:"api_keys,omitempty"` +}) (*mcp.CallToolResult, SubscriptionCapabilities, error) { + result := s.handleSubscriptionDetect(ctx, platformOptions(core.Option{Key: "api_keys", Value: input.APIKeys})) + if !result.OK { + return nil, SubscriptionCapabilities{}, resultErrorValue("agentic.subscription.detect", result) + } + output, ok := result.Value.(SubscriptionCapabilities) + if !ok { + return nil, SubscriptionCapabilities{}, core.E("agentic.subscription.detect", "invalid capability output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) subscriptionBudgetTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + AgentID string `json:"agent_id"` +}) (*mcp.CallToolResult, map[string]any, error) { + result := s.handleSubscriptionBudget(ctx, platformOptions(core.Option{Key: "agent_id", Value: input.AgentID})) + if !result.OK { + return nil, nil, resultErrorValue("agentic.subscription.budget", result) + } + output, ok := result.Value.(map[string]any) + if !ok { + return nil, nil, core.E("agentic.subscription.budget", "invalid budget output", nil) + } + return nil, output, nil +} + +func (s *PrepSubsystem) subscriptionBudgetUpdateTool(ctx context.Context, _ *mcp.CallToolRequest, input struct { + AgentID string `json:"agent_id"` + Limits map[string]any `json:"limits"` +}) (*mcp.CallToolResult, map[string]any, error) { + result := s.handleSubscriptionBudgetUpdate(ctx, platformOptions( + core.Option{Key: "agent_id", Value: input.AgentID}, + core.Option{Key: "limits", Value: input.Limits}, + )) + if !result.OK { + return nil, nil, resultErrorValue("agentic.subscription.budget.update", result) + } + output, ok := result.Value.(map[string]any) + if !ok { + return nil, nil, core.E("agentic.subscription.budget.update", "invalid updated budget output", nil) + } + return nil, output, nil +} + +func platformOptions(options ...core.Option) core.Options { + filtered := make([]core.Option, 0, len(options)) + for _, option := range options { + switch value := option.Value.(type) { + case string: + if core.Trim(value) == "" { + continue + } + case []string: + if len(value) == 0 { + continue + } + case map[string]any: + if len(value) == 0 { + continue + } + case map[string]string: + if len(value) == 0 { + continue + } + case int: + if value == 0 { + continue + } + } + filtered = append(filtered, option) + } + return core.NewOptions(filtered...) +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 24a1ee9..c91cc20 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -180,6 +180,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("plan.get", s.handlePlanGet).Description = "Read an implementation plan by ID or slug" c.Action("plan.read", s.handlePlanRead).Description = "Read an implementation plan by ID" c.Action("plan.update", s.handlePlanUpdate).Description = "Update plan status, phases, notes, or agent assignment" + c.Action("plan.update_status", s.handlePlanUpdateStatus).Description = "Update an implementation plan lifecycle status by slug" c.Action("plan.archive", s.handlePlanArchive).Description = "Archive an implementation plan by slug" c.Action("plan.delete", s.handlePlanDelete).Description = "Delete an implementation plan by ID" c.Action("plan.list", s.handlePlanList).Description = "List implementation plans with optional filters" @@ -204,6 +205,17 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("template.list", s.handleTemplateList).Description = "List available YAML plan templates" c.Action("template.preview", s.handleTemplatePreview).Description = "Preview a YAML plan template with variable substitution" c.Action("template.create_plan", s.handleTemplateCreatePlan).Description = "Create a stored plan from a YAML template" + c.Action("issue.create", s.handleIssueRecordCreate).Description = "Create a tracked platform issue" + c.Action("issue.get", s.handleIssueRecordGet).Description = "Read a tracked platform issue by slug" + c.Action("issue.list", s.handleIssueRecordList).Description = "List tracked platform issues with optional filters" + c.Action("issue.update", s.handleIssueRecordUpdate).Description = "Update a tracked platform issue by slug" + c.Action("issue.comment", s.handleIssueRecordComment).Description = "Add a comment to a tracked platform issue" + c.Action("issue.archive", s.handleIssueRecordArchive).Description = "Archive a tracked platform issue by slug" + c.Action("sprint.create", s.handleSprintCreate).Description = "Create a tracked platform sprint" + c.Action("sprint.get", s.handleSprintGet).Description = "Read a tracked platform sprint by slug" + c.Action("sprint.list", s.handleSprintList).Description = "List tracked platform sprints with optional filters" + c.Action("sprint.update", s.handleSprintUpdate).Description = "Update a tracked platform sprint by slug" + c.Action("sprint.archive", s.handleSprintArchive).Description = "Archive a tracked platform sprint by slug" c.Action("agentic.prompt", s.handlePrompt).Description = "Read a system prompt by slug" c.Action("agentic.task", s.handleTask).Description = "Read a task plan by slug" @@ -305,12 +317,15 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { s.registerRemoteDispatchTool(server) s.registerRemoteStatusTool(server) s.registerReviewQueueTool(server) + s.registerPlatformTools(server) s.registerShutdownTools(server) s.registerSessionTools(server) s.registerStateTools(server) s.registerPhaseTools(server) s.registerTaskTools(server) s.registerTemplateTools(server) + s.registerIssueTools(server) + s.registerSprintTools(server) mcp.AddTool(server, &mcp.Tool{ Name: "agentic_scan", diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 4bb8756..d2c7fe4 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -444,6 +444,7 @@ func TestPrep_OnStartup_Good_RegistersPlanActions(t *testing.T) { assert.True(t, c.Action("plan.get").Exists()) assert.True(t, c.Action("plan.read").Exists()) assert.True(t, c.Action("plan.update").Exists()) + assert.True(t, c.Action("plan.update_status").Exists()) assert.True(t, c.Action("plan.archive").Exists()) assert.True(t, c.Action("plan.delete").Exists()) assert.True(t, c.Action("plan.list").Exists()) @@ -476,6 +477,17 @@ func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { assert.True(t, c.Action("state.set").Exists()) assert.True(t, c.Action("state.get").Exists()) assert.True(t, c.Action("state.list").Exists()) + assert.True(t, c.Action("issue.create").Exists()) + assert.True(t, c.Action("issue.get").Exists()) + assert.True(t, c.Action("issue.list").Exists()) + assert.True(t, c.Action("issue.update").Exists()) + assert.True(t, c.Action("issue.comment").Exists()) + assert.True(t, c.Action("issue.archive").Exists()) + assert.True(t, c.Action("sprint.create").Exists()) + assert.True(t, c.Action("sprint.get").Exists()) + assert.True(t, c.Action("sprint.list").Exists()) + assert.True(t, c.Action("sprint.update").Exists()) + assert.True(t, c.Action("sprint.archive").Exists()) } func TestPrep_OnStartup_Good_RegistersPlatformActionAliases(t *testing.T) { diff --git a/pkg/agentic/sprint.go b/pkg/agentic/sprint.go new file mode 100644 index 0000000..59d403d --- /dev/null +++ b/pkg/agentic/sprint.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// sprint := agentic.Sprint{Slug: "ax-follow-up", Title: "AX Follow-up", Status: "active"} +type Sprint struct { + ID int `json:"id"` + WorkspaceID int `json:"workspace_id,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` + Goal string `json:"goal,omitempty"` + Status string `json:"status,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// input := agentic.SprintCreateInput{Title: "AX Follow-up", Goal: "Finish RFC parity"} +type SprintCreateInput struct { + Title string `json:"title"` + Goal string `json:"goal,omitempty"` + Status string `json:"status,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` +} + +// input := agentic.SprintGetInput{Slug: "ax-follow-up"} +type SprintGetInput struct { + Slug string `json:"slug"` +} + +// input := agentic.SprintListInput{Status: "active", Limit: 10} +type SprintListInput struct { + Status string `json:"status,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// input := agentic.SprintUpdateInput{Slug: "ax-follow-up", Status: "completed"} +type SprintUpdateInput struct { + Slug string `json:"slug"` + Title string `json:"title,omitempty"` + Goal string `json:"goal,omitempty"` + Status string `json:"status,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` +} + +// input := agentic.SprintArchiveInput{Slug: "ax-follow-up"} +type SprintArchiveInput struct { + Slug string `json:"slug"` +} + +// out := agentic.SprintOutput{Success: true, Sprint: agentic.Sprint{Slug: "ax-follow-up"}} +type SprintOutput struct { + Success bool `json:"success"` + Sprint Sprint `json:"sprint"` +} + +// out := agentic.SprintListOutput{Success: true, Count: 1, Sprints: []agentic.Sprint{{Slug: "ax-follow-up"}}} +type SprintListOutput struct { + Success bool `json:"success"` + Count int `json:"count"` + Sprints []Sprint `json:"sprints"` +} + +// out := agentic.SprintArchiveOutput{Success: true, Archived: "ax-follow-up"} +type SprintArchiveOutput struct { + Success bool `json:"success"` + Archived string `json:"archived"` +} + +// result := c.Action("sprint.create").Run(ctx, core.NewOptions(core.Option{Key: "title", Value: "AX Follow-up"})) +func (s *PrepSubsystem) handleSprintCreate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.sprintCreate(ctx, nil, SprintCreateInput{ + Title: optionStringValue(options, "title"), + Goal: optionStringValue(options, "goal"), + Status: optionStringValue(options, "status"), + Metadata: optionAnyMapValue(options, "metadata"), + StartedAt: optionStringValue(options, "started_at", "started-at"), + EndedAt: optionStringValue(options, "ended_at", "ended-at"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("sprint.get").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "ax-follow-up"})) +func (s *PrepSubsystem) handleSprintGet(ctx context.Context, options core.Options) core.Result { + _, output, err := s.sprintGet(ctx, nil, SprintGetInput{ + Slug: optionStringValue(options, "slug", "_arg"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("sprint.list").Run(ctx, core.NewOptions(core.Option{Key: "status", Value: "active"})) +func (s *PrepSubsystem) handleSprintList(ctx context.Context, options core.Options) core.Result { + _, output, err := s.sprintList(ctx, nil, SprintListInput{ + Status: optionStringValue(options, "status"), + Limit: optionIntValue(options, "limit"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("sprint.update").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "ax-follow-up"})) +func (s *PrepSubsystem) handleSprintUpdate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.sprintUpdate(ctx, nil, SprintUpdateInput{ + Slug: optionStringValue(options, "slug", "_arg"), + Title: optionStringValue(options, "title"), + Goal: optionStringValue(options, "goal"), + Status: optionStringValue(options, "status"), + Metadata: optionAnyMapValue(options, "metadata"), + StartedAt: optionStringValue(options, "started_at", "started-at"), + EndedAt: optionStringValue(options, "ended_at", "ended-at"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("sprint.archive").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "ax-follow-up"})) +func (s *PrepSubsystem) handleSprintArchive(ctx context.Context, options core.Options) core.Result { + _, output, err := s.sprintArchive(ctx, nil, SprintArchiveInput{ + Slug: optionStringValue(options, "slug", "_arg"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerSprintTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "sprint_create", + Description: "Create a tracked platform sprint with goal, schedule, and metadata.", + }, s.sprintCreate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sprint_get", + Description: "Read a tracked platform sprint by slug.", + }, s.sprintGet) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sprint_list", + Description: "List tracked platform sprints with optional status and limit filters.", + }, s.sprintList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sprint_update", + Description: "Update fields on a tracked platform sprint by slug.", + }, s.sprintUpdate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sprint_archive", + Description: "Archive a tracked platform sprint by slug.", + }, s.sprintArchive) +} + +func (s *PrepSubsystem) sprintCreate(ctx context.Context, _ *mcp.CallToolRequest, input SprintCreateInput) (*mcp.CallToolResult, SprintOutput, error) { + if input.Title == "" { + return nil, SprintOutput{}, core.E("sprintCreate", "title is required", nil) + } + + body := map[string]any{ + "title": input.Title, + } + if input.Goal != "" { + body["goal"] = input.Goal + } + if input.Status != "" { + body["status"] = input.Status + } + if len(input.Metadata) > 0 { + body["metadata"] = input.Metadata + } + if input.StartedAt != "" { + body["started_at"] = input.StartedAt + } + if input.EndedAt != "" { + body["ended_at"] = input.EndedAt + } + + result := s.platformPayload(ctx, "sprint.create", "POST", "/v1/sprints", body) + if !result.OK { + return nil, SprintOutput{}, resultErrorValue("sprint.create", result) + } + + return nil, SprintOutput{ + Success: true, + Sprint: parseSprint(payloadResourceMap(result.Value.(map[string]any), "sprint")), + }, nil +} + +func (s *PrepSubsystem) sprintGet(ctx context.Context, _ *mcp.CallToolRequest, input SprintGetInput) (*mcp.CallToolResult, SprintOutput, error) { + if input.Slug == "" { + return nil, SprintOutput{}, core.E("sprintGet", "slug is required", nil) + } + + result := s.platformPayload(ctx, "sprint.get", "GET", core.Concat("/v1/sprints/", input.Slug), nil) + if !result.OK { + return nil, SprintOutput{}, resultErrorValue("sprint.get", result) + } + + return nil, SprintOutput{ + Success: true, + Sprint: parseSprint(payloadResourceMap(result.Value.(map[string]any), "sprint")), + }, nil +} + +func (s *PrepSubsystem) sprintList(ctx context.Context, _ *mcp.CallToolRequest, input SprintListInput) (*mcp.CallToolResult, SprintListOutput, error) { + path := "/v1/sprints" + path = appendQueryParam(path, "status", input.Status) + if input.Limit > 0 { + path = appendQueryParam(path, "limit", core.Sprint(input.Limit)) + } + + result := s.platformPayload(ctx, "sprint.list", "GET", path, nil) + if !result.OK { + return nil, SprintListOutput{}, resultErrorValue("sprint.list", result) + } + + return nil, parseSprintListOutput(result.Value.(map[string]any)), nil +} + +func (s *PrepSubsystem) sprintUpdate(ctx context.Context, _ *mcp.CallToolRequest, input SprintUpdateInput) (*mcp.CallToolResult, SprintOutput, error) { + if input.Slug == "" { + return nil, SprintOutput{}, core.E("sprintUpdate", "slug is required", nil) + } + + body := map[string]any{} + if input.Title != "" { + body["title"] = input.Title + } + if input.Goal != "" { + body["goal"] = input.Goal + } + if input.Status != "" { + body["status"] = input.Status + } + if len(input.Metadata) > 0 { + body["metadata"] = input.Metadata + } + if input.StartedAt != "" { + body["started_at"] = input.StartedAt + } + if input.EndedAt != "" { + body["ended_at"] = input.EndedAt + } + if len(body) == 0 { + return nil, SprintOutput{}, core.E("sprintUpdate", "at least one field is required", nil) + } + + result := s.platformPayload(ctx, "sprint.update", "PATCH", core.Concat("/v1/sprints/", input.Slug), body) + if !result.OK { + return nil, SprintOutput{}, resultErrorValue("sprint.update", result) + } + + return nil, SprintOutput{ + Success: true, + Sprint: parseSprint(payloadResourceMap(result.Value.(map[string]any), "sprint")), + }, nil +} + +func (s *PrepSubsystem) sprintArchive(ctx context.Context, _ *mcp.CallToolRequest, input SprintArchiveInput) (*mcp.CallToolResult, SprintArchiveOutput, error) { + if input.Slug == "" { + return nil, SprintArchiveOutput{}, core.E("sprintArchive", "slug is required", nil) + } + + result := s.platformPayload(ctx, "sprint.archive", "DELETE", core.Concat("/v1/sprints/", input.Slug), nil) + if !result.OK { + return nil, SprintArchiveOutput{}, resultErrorValue("sprint.archive", result) + } + + output := SprintArchiveOutput{ + Success: true, + Archived: input.Slug, + } + if values := payloadResourceMap(result.Value.(map[string]any), "sprint", "result"); len(values) > 0 { + if slug := stringValue(values["slug"]); slug != "" { + output.Archived = slug + } + if value, ok := boolValueOK(values["success"]); ok { + output.Success = value + } + } + return nil, output, nil +} + +func parseSprint(values map[string]any) Sprint { + return Sprint{ + ID: intValue(values["id"]), + WorkspaceID: intValue(values["workspace_id"]), + Slug: stringValue(values["slug"]), + Title: stringValue(values["title"]), + Goal: stringValue(values["goal"]), + Status: stringValue(values["status"]), + Metadata: anyMapValue(values["metadata"]), + StartedAt: stringValue(values["started_at"]), + EndedAt: stringValue(values["ended_at"]), + CreatedAt: stringValue(values["created_at"]), + UpdatedAt: stringValue(values["updated_at"]), + } +} + +func parseSprintListOutput(payload map[string]any) SprintListOutput { + sprintsData := payloadDataSlice(payload, "sprints") + sprints := make([]Sprint, 0, len(sprintsData)) + for _, values := range sprintsData { + sprints = append(sprints, parseSprint(values)) + } + + count := mapIntValue(payload, "total", "count") + if count == 0 { + count = mapIntValue(payloadDataMap(payload), "total", "count") + } + if count == 0 { + count = len(sprints) + } + + return SprintListOutput{ + Success: true, + Count: count, + Sprints: sprints, + } +} diff --git a/pkg/agentic/sprint_test.go b/pkg/agentic/sprint_test.go new file mode 100644 index 0000000..a583f8d --- /dev/null +++ b/pkg/agentic/sprint_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSprint_HandleSprintCreate_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/sprints", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "AX Follow-up", payload["title"]) + require.Equal(t, "Finish RFC parity", payload["goal"]) + + _, _ = w.Write([]byte(`{"data":{"slug":"ax-follow-up","title":"AX Follow-up","goal":"Finish RFC parity","status":"active"}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleSprintCreate(context.Background(), core.NewOptions( + core.Option{Key: "title", Value: "AX Follow-up"}, + core.Option{Key: "goal", Value: "Finish RFC parity"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(SprintOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "ax-follow-up", output.Sprint.Slug) + assert.Equal(t, "active", output.Sprint.Status) +} + +func TestSprint_HandleSprintGet_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleSprintGet(context.Background(), core.NewOptions()) + assert.False(t, result.OK) + assert.EqualError(t, result.Value.(error), "sprintGet: slug is required") +} + +func TestSprint_HandleSprintList_Ugly_NestedEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/sprints", r.URL.Path) + require.Equal(t, "active", r.URL.Query().Get("status")) + _, _ = w.Write([]byte(`{"data":{"sprints":[{"id":4,"workspace_id":2,"slug":"ax-follow-up","title":"AX Follow-up","goal":"Finish RFC parity","status":"active"}],"total":1}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleSprintList(context.Background(), core.NewOptions( + core.Option{Key: "status", Value: "active"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(SprintListOutput) + require.True(t, ok) + require.Len(t, output.Sprints, 1) + assert.Equal(t, 1, output.Count) + assert.Equal(t, 2, output.Sprints[0].WorkspaceID) + assert.Equal(t, "Finish RFC parity", output.Sprints[0].Goal) +}