feat(agentic): add issue and sprint platform surfaces

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 15:12:33 +00:00
parent 130b2c84d1
commit 8ed911eb27
8 changed files with 1461 additions and 0 deletions

461
pkg/agentic/issue.go Normal file
View file

@ -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,
}
}

80
pkg/agentic/issue_test.go Normal file
View file

@ -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)
}

View file

@ -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 {

View file

@ -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...)
}

View file

@ -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",

View file

@ -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) {

343
pkg/agentic/sprint.go Normal file
View file

@ -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,
}
}

View file

@ -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)
}