diff --git a/pkg/agentic/commands.go b/pkg/agentic/commands.go index 4e05883..4936029 100644 --- a/pkg/agentic/commands.go +++ b/pkg/agentic/commands.go @@ -80,6 +80,7 @@ func (s *PrepSubsystem) registerCommands(ctx context.Context) { s.registerCommitCommands() s.registerSessionCommands() s.registerTaskCommands() + s.registerSprintCommands() s.registerStateCommands() s.registerLanguageCommands() s.registerSetupCommands() diff --git a/pkg/agentic/commands_sprint.go b/pkg/agentic/commands_sprint.go new file mode 100644 index 0000000..2296b48 --- /dev/null +++ b/pkg/agentic/commands_sprint.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func (s *PrepSubsystem) registerSprintCommands() { + c := s.Core() + c.Command("sprint", core.Command{Description: "Manage tracked platform sprints", Action: s.cmdSprint}) + c.Command("agentic:sprint", core.Command{Description: "Manage tracked platform sprints", Action: s.cmdSprint}) + c.Command("sprint/create", core.Command{Description: "Create a tracked platform sprint", Action: s.cmdSprintCreate}) + c.Command("agentic:sprint/create", core.Command{Description: "Create a tracked platform sprint", Action: s.cmdSprintCreate}) + c.Command("sprint/get", core.Command{Description: "Read a tracked platform sprint by slug or ID", Action: s.cmdSprintGet}) + c.Command("agentic:sprint/get", core.Command{Description: "Read a tracked platform sprint by slug or ID", Action: s.cmdSprintGet}) + c.Command("sprint/list", core.Command{Description: "List tracked platform sprints", Action: s.cmdSprintList}) + c.Command("agentic:sprint/list", core.Command{Description: "List tracked platform sprints", Action: s.cmdSprintList}) + c.Command("sprint/update", core.Command{Description: "Update a tracked platform sprint", Action: s.cmdSprintUpdate}) + c.Command("agentic:sprint/update", core.Command{Description: "Update a tracked platform sprint", Action: s.cmdSprintUpdate}) + c.Command("sprint/archive", core.Command{Description: "Archive a tracked platform sprint", Action: s.cmdSprintArchive}) + c.Command("agentic:sprint/archive", core.Command{Description: "Archive a tracked platform sprint", Action: s.cmdSprintArchive}) +} + +func (s *PrepSubsystem) cmdSprint(options core.Options) core.Result { + action := optionStringValue(options, "action") + switch action { + case "create": + return s.cmdSprintCreate(options) + case "get", "show": + return s.cmdSprintGet(options) + case "list": + return s.cmdSprintList(options) + case "update": + return s.cmdSprintUpdate(options) + case "archive", "delete": + return s.cmdSprintArchive(options) + case "": + core.Print(nil, "usage: core-agent sprint create --title=\"AX Follow-up\" [--goal=\"Finish RFC parity\"] [--status=active]") + core.Print(nil, " core-agent sprint get ") + core.Print(nil, " core-agent sprint list [--status=active] [--limit=10]") + core.Print(nil, " core-agent sprint update [--title=\"...\"] [--goal=\"...\"] [--status=completed]") + core.Print(nil, " core-agent sprint archive ") + return core.Result{OK: true} + default: + core.Print(nil, "usage: core-agent sprint create --title=\"AX Follow-up\" [--goal=\"Finish RFC parity\"] [--status=active]") + core.Print(nil, " core-agent sprint get ") + core.Print(nil, " core-agent sprint list [--status=active] [--limit=10]") + core.Print(nil, " core-agent sprint update [--title=\"...\"] [--goal=\"...\"] [--status=completed]") + core.Print(nil, " core-agent sprint archive ") + return core.Result{Value: core.E("agentic.cmdSprint", core.Concat("unknown sprint command: ", action), nil), OK: false} + } +} + +func (s *PrepSubsystem) cmdSprintCreate(options core.Options) core.Result { + title := optionStringValue(options, "title") + if title == "" { + core.Print(nil, "usage: core-agent sprint create --title=\"AX Follow-up\" [--goal=\"Finish RFC parity\"] [--status=active]") + return core.Result{Value: core.E("agentic.cmdSprintCreate", "title is required", nil), OK: false} + } + + result := s.handleSprintCreate(s.commandContext(), core.NewOptions( + core.Option{Key: "title", Value: title}, + core.Option{Key: "goal", Value: optionStringValue(options, "goal")}, + core.Option{Key: "status", Value: optionStringValue(options, "status")}, + core.Option{Key: "metadata", Value: optionAnyMapValue(options, "metadata")}, + core.Option{Key: "started_at", Value: optionStringValue(options, "started_at", "started-at")}, + core.Option{Key: "ended_at", Value: optionStringValue(options, "ended_at", "ended-at")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSprintCreate", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SprintOutput) + if !ok { + err := core.E("agentic.cmdSprintCreate", "invalid sprint create output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "slug: %s", output.Sprint.Slug) + core.Print(nil, "title: %s", output.Sprint.Title) + core.Print(nil, "status: %s", output.Sprint.Status) + if output.Sprint.Goal != "" { + core.Print(nil, "goal: %s", output.Sprint.Goal) + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdSprintGet(options core.Options) core.Result { + identifier := optionStringValue(options, "slug", "id", "_arg") + if identifier == "" { + core.Print(nil, "usage: core-agent sprint get ") + return core.Result{Value: core.E("agentic.cmdSprintGet", "id or slug is required", nil), OK: false} + } + + result := s.handleSprintGet(s.commandContext(), core.NewOptions( + core.Option{Key: "slug", Value: optionStringValue(options, "slug")}, + core.Option{Key: "id", Value: optionStringValue(options, "id", "_arg")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSprintGet", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SprintOutput) + if !ok { + err := core.E("agentic.cmdSprintGet", "invalid sprint get output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "slug: %s", output.Sprint.Slug) + core.Print(nil, "title: %s", output.Sprint.Title) + core.Print(nil, "status: %s", output.Sprint.Status) + if output.Sprint.Goal != "" { + core.Print(nil, "goal: %s", output.Sprint.Goal) + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdSprintList(options core.Options) core.Result { + result := s.handleSprintList(s.commandContext(), core.NewOptions( + core.Option{Key: "status", Value: optionStringValue(options, "status")}, + core.Option{Key: "limit", Value: optionIntValue(options, "limit")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSprintList", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SprintListOutput) + if !ok { + err := core.E("agentic.cmdSprintList", "invalid sprint list output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + if output.Count == 0 { + core.Print(nil, "no sprints") + return core.Result{Value: output, OK: true} + } + + for _, sprint := range output.Sprints { + core.Print(nil, " %-10s %-24s %s", sprint.Status, sprint.Slug, sprint.Title) + } + core.Print(nil, "%d sprint(s)", output.Count) + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdSprintUpdate(options core.Options) core.Result { + identifier := optionStringValue(options, "slug", "id", "_arg") + if identifier == "" { + core.Print(nil, "usage: core-agent sprint update [--title=\"...\"] [--goal=\"...\"] [--status=completed]") + return core.Result{Value: core.E("agentic.cmdSprintUpdate", "id or slug is required", nil), OK: false} + } + + result := s.handleSprintUpdate(s.commandContext(), core.NewOptions( + core.Option{Key: "slug", Value: optionStringValue(options, "slug")}, + core.Option{Key: "id", Value: optionStringValue(options, "id", "_arg")}, + core.Option{Key: "title", Value: optionStringValue(options, "title")}, + core.Option{Key: "goal", Value: optionStringValue(options, "goal")}, + core.Option{Key: "status", Value: optionStringValue(options, "status")}, + core.Option{Key: "metadata", Value: optionAnyMapValue(options, "metadata")}, + core.Option{Key: "started_at", Value: optionStringValue(options, "started_at", "started-at")}, + core.Option{Key: "ended_at", Value: optionStringValue(options, "ended_at", "ended-at")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSprintUpdate", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SprintOutput) + if !ok { + err := core.E("agentic.cmdSprintUpdate", "invalid sprint update output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "slug: %s", output.Sprint.Slug) + core.Print(nil, "title: %s", output.Sprint.Title) + core.Print(nil, "status: %s", output.Sprint.Status) + if output.Sprint.Goal != "" { + core.Print(nil, "goal: %s", output.Sprint.Goal) + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdSprintArchive(options core.Options) core.Result { + identifier := optionStringValue(options, "slug", "id", "_arg") + if identifier == "" { + core.Print(nil, "usage: core-agent sprint archive ") + return core.Result{Value: core.E("agentic.cmdSprintArchive", "id or slug is required", nil), OK: false} + } + + result := s.handleSprintArchive(s.commandContext(), core.NewOptions( + core.Option{Key: "slug", Value: optionStringValue(options, "slug")}, + core.Option{Key: "id", Value: optionStringValue(options, "id", "_arg")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSprintArchive", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SprintArchiveOutput) + if !ok { + err := core.E("agentic.cmdSprintArchive", "invalid sprint archive output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "archived: %s", output.Archived) + return core.Result{Value: output, OK: true} +} diff --git a/pkg/agentic/commands_sprint_test.go b/pkg/agentic/commands_sprint_test.go new file mode 100644 index 0000000..b403bc9 --- /dev/null +++ b/pkg/agentic/commands_sprint_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandsSprint_RegisterCommands_Good(t *testing.T) { + c := core.New(core.WithOption("name", "test")) + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{})} + + s.registerSprintCommands() + + assert.Contains(t, c.Commands(), "sprint") + assert.Contains(t, c.Commands(), "agentic:sprint") + assert.Contains(t, c.Commands(), "sprint/create") + assert.Contains(t, c.Commands(), "agentic:sprint/create") + assert.Contains(t, c.Commands(), "sprint/get") + assert.Contains(t, c.Commands(), "agentic:sprint/get") + assert.Contains(t, c.Commands(), "sprint/list") + assert.Contains(t, c.Commands(), "agentic:sprint/list") + assert.Contains(t, c.Commands(), "sprint/update") + assert.Contains(t, c.Commands(), "agentic:sprint/update") + assert.Contains(t, c.Commands(), "sprint/archive") + assert.Contains(t, c.Commands(), "agentic:sprint/archive") +} + +func TestCommandsSprint_CmdSprintCreate_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 + require.True(t, core.JSONUnmarshalString(bodyResult.Value.(string), &payload).OK) + require.Equal(t, "AX Follow-up", payload["title"]) + require.Equal(t, "Finish RFC parity", payload["goal"]) + require.Equal(t, "active", payload["status"]) + + _, _ = w.Write([]byte(`{"data":{"sprint":{"id":7,"slug":"ax-follow-up","title":"AX Follow-up","goal":"Finish RFC parity","status":"active"}}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + output := captureStdout(t, func() { + result := subsystem.cmdSprintCreate(core.NewOptions( + core.Option{Key: "title", Value: "AX Follow-up"}, + core.Option{Key: "goal", Value: "Finish RFC parity"}, + core.Option{Key: "status", Value: "active"}, + )) + require.True(t, result.OK) + }) + + assert.Contains(t, output, "slug: ax-follow-up") + assert.Contains(t, output, "title: AX Follow-up") + assert.Contains(t, output, "status: active") + assert.Contains(t, output, "goal: Finish RFC parity") +} + +func TestCommandsSprint_CmdSprintList_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, "active", r.URL.Query().Get("status")) + require.Equal(t, "5", r.URL.Query().Get("limit")) + + _, _ = w.Write([]byte(`{"data":[{"id":1,"slug":"ax-follow-up","title":"AX Follow-up","status":"active"},{"id":2,"slug":"rfc-parity","title":"RFC Parity","status":"active"}],"count":2}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + output := captureStdout(t, func() { + result := subsystem.cmdSprintList(core.NewOptions( + core.Option{Key: "status", Value: "active"}, + core.Option{Key: "limit", Value: 5}, + )) + require.True(t, result.OK) + }) + + assert.Contains(t, output, "ax-follow-up") + assert.Contains(t, output, "rfc-parity") + assert.Contains(t, output, "2 sprint(s)") +} + +func TestCommandsSprint_CmdSprintArchive_Bad_MissingIdentifier(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.cmdSprintArchive(core.NewOptions()) + + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "id or slug is required") +} + +func TestCommandsSprint_CmdSprintGet_Ugly_InvalidResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.cmdSprintGet(core.NewOptions(core.Option{Key: "_arg", Value: "ax-follow-up"})) + assert.False(t, result.OK) +} diff --git a/pkg/agentic/commands_test.go b/pkg/agentic/commands_test.go index 6ffbc65..6f7e935 100644 --- a/pkg/agentic/commands_test.go +++ b/pkg/agentic/commands_test.go @@ -1523,6 +1523,8 @@ func TestCommands_RegisterCommands_Good_AllRegistered(t *testing.T) { assert.Contains(t, cmds, "task") assert.Contains(t, cmds, "task/update") assert.Contains(t, cmds, "task/toggle") + assert.Contains(t, cmds, "sprint") + assert.Contains(t, cmds, "sprint/create") } func TestCommands_CmdPRManage_Good_NoCandidates(t *testing.T) {