From 3c2ab16afb830ff145cfa5541e5e3b6cccd647ba Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 15:42:26 +0000 Subject: [PATCH] feat(agentic): add plan from issue command Co-Authored-By: Virgil --- pkg/agentic/commands_plan.go | 27 ++++++ pkg/agentic/plan_from_issue.go | 134 ++++++++++++++++++++++++++++ pkg/agentic/plan_from_issue_test.go | 107 ++++++++++++++++++++++ pkg/agentic/prep.go | 1 + pkg/agentic/prep_test.go | 2 + 5 files changed, 271 insertions(+) create mode 100644 pkg/agentic/plan_from_issue.go create mode 100644 pkg/agentic/plan_from_issue_test.go diff --git a/pkg/agentic/commands_plan.go b/pkg/agentic/commands_plan.go index e360d02..9adcb33 100644 --- a/pkg/agentic/commands_plan.go +++ b/pkg/agentic/commands_plan.go @@ -10,6 +10,7 @@ func (s *PrepSubsystem) registerPlanCommands() { c := s.Core() c.Command("plan", core.Command{Description: "Manage implementation plans", Action: s.cmdPlan}) c.Command("plan/create", core.Command{Description: "Create an implementation plan or create one from a template", Action: s.cmdPlanCreate}) + c.Command("plan/from-issue", core.Command{Description: "Create an implementation plan from a tracked issue", Action: s.cmdPlanFromIssue}) c.Command("plan/list", core.Command{Description: "List implementation plans", Action: s.cmdPlanList}) c.Command("plan/show", core.Command{Description: "Show an implementation plan", Action: s.cmdPlanShow}) c.Command("plan/status", core.Command{Description: "Read or update an implementation plan status", Action: s.cmdPlanStatus}) @@ -88,6 +89,32 @@ func (s *PrepSubsystem) cmdPlanCreate(options core.Options) core.Result { return core.Result{Value: output, OK: true} } +func (s *PrepSubsystem) cmdPlanFromIssue(options core.Options) core.Result { + ctx := s.commandContext() + identifier := optionStringValue(options, "slug", "_arg") + if identifier == "" { + identifier = optionStringValue(options, "id") + } + if identifier == "" { + core.Print(nil, "usage: core-agent plan from-issue [--id=N]") + return core.Result{Value: core.E("agentic.cmdPlanFromIssue", "issue slug or id is required", nil), OK: false} + } + + _, output, err := s.planFromIssue(ctx, nil, PlanFromIssueInput{ + ID: optionStringValue(options, "id", "_arg"), + Slug: optionStringValue(options, "slug"), + }) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "created: %s", output.Plan.Slug) + core.Print(nil, "issue: #%d %s", output.Issue.ID, output.Issue.Title) + core.Print(nil, "path: %s", output.Path) + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) cmdPlanList(options core.Options) core.Result { ctx := s.commandContext() _, output, err := s.planList(ctx, nil, PlanListInput{ diff --git a/pkg/agentic/plan_from_issue.go b/pkg/agentic/plan_from_issue.go new file mode 100644 index 0000000..03edfc7 --- /dev/null +++ b/pkg/agentic/plan_from_issue.go @@ -0,0 +1,134 @@ +// 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"} +// result := c.Action("plan.from.issue").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) +type PlanFromIssueInput struct { + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` +} + +// output := agentic.PlanFromIssueOutput{Success: true} +type PlanFromIssueOutput struct { + Success bool `json:"success"` + Issue Issue `json:"issue"` + Plan Plan `json:"plan"` + Path string `json:"path,omitempty"` +} + +// result := c.Action("plan.from.issue").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) +func (s *PrepSubsystem) handlePlanFromIssue(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planFromIssue(ctx, nil, PlanFromIssueInput{ + ID: optionStringValue(options, "id", "_arg"), + Slug: optionStringValue(options, "slug"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) planFromIssue(ctx context.Context, _ *mcp.CallToolRequest, input PlanFromIssueInput) (*mcp.CallToolResult, PlanFromIssueOutput, error) { + identifier := issueRecordIdentifier(input.Slug, input.ID) + if identifier == "" { + return nil, PlanFromIssueOutput{}, core.E("planFromIssue", "issue slug or id is required", nil) + } + + issueResult := s.handleIssueRecordGet(ctx, core.NewOptions( + core.Option{Key: "id", Value: input.ID}, + core.Option{Key: "slug", Value: input.Slug}, + )) + if !issueResult.OK { + if value, ok := issueResult.Value.(error); ok { + return nil, PlanFromIssueOutput{}, value + } + return nil, PlanFromIssueOutput{}, core.E("planFromIssue", "failed to read issue", nil) + } + + issueOutput, ok := issueResult.Value.(IssueOutput) + if !ok { + return nil, PlanFromIssueOutput{}, core.E("planFromIssue", "invalid issue output", nil) + } + if issueOutput.Issue.Title == "" { + return nil, PlanFromIssueOutput{}, core.E("planFromIssue", "issue title is required", nil) + } + + description := issueOutput.Issue.Description + objective := description + if objective == "" { + objective = issueOutput.Issue.Title + } + + phaseTitle := core.Concat("Resolve issue: ", issueOutput.Issue.Title) + phaseCriteria := []string{"Issue is closed", "QA passes"} + if len(issueOutput.Issue.Labels) > 0 { + phaseCriteria = append(phaseCriteria, "Issue labels are preserved in the plan context") + } + + _, createOutput, err := s.planCreate(ctx, nil, PlanCreateInput{ + Title: issueOutput.Issue.Title, + Slug: planFromIssueSlug(issueOutput.Issue.Slug), + Objective: objective, + Description: description, + Context: map[string]any{ + "source_issue": issueOutput.Issue, + "source_issue_id": issueOutput.Issue.ID, + "source_issue_slug": issueOutput.Issue.Slug, + "source_issue_type": issueOutput.Issue.Type, + "source_issue_labels": issueOutput.Issue.Labels, + "source_issue_state": issueOutput.Issue.Status, + "source_issue_meta": issueOutput.Issue.Metadata, + }, + Phases: []Phase{ + { + Number: 1, + Name: phaseTitle, + Description: description, + Criteria: phaseCriteria, + }, + }, + Notes: core.Concat("Created from issue ", identifier), + }) + if err != nil { + return nil, PlanFromIssueOutput{}, err + } + + planResult := readPlanResult(PlansRoot(), createOutput.ID) + if !planResult.OK { + err, _ := planResult.Value.(error) + if err == nil { + err = core.E("planFromIssue", "failed to read created plan", nil) + } + return nil, PlanFromIssueOutput{}, err + } + plan, ok := planResult.Value.(*Plan) + if !ok || plan == nil { + return nil, PlanFromIssueOutput{}, core.E("planFromIssue", "invalid created plan", nil) + } + + return nil, PlanFromIssueOutput{ + Success: true, + Issue: issueOutput.Issue, + Plan: *plan, + Path: createOutput.Path, + }, nil +} + +// plan-from-issue fixtures should use issue slugs like `fix-auth`. +func planFromIssueSlug(slug string) string { + if slug == "" { + return "" + } + if core.HasPrefix(slug, "issue-") { + return slug + } + return core.Concat("issue-", slug) +} diff --git a/pkg/agentic/plan_from_issue_test.go b/pkg/agentic/plan_from_issue_test.go new file mode 100644 index 0000000..5711636 --- /dev/null +++ b/pkg/agentic/plan_from_issue_test.go @@ -0,0 +1,107 @@ +// 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 TestPlanFromIssue_PlanFromIssue_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + t.Setenv("CORE_AGENT_API_KEY", "secret-token") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/issues/fix-auth", r.URL.Path) + require.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"data":{"issue":{"id":17,"slug":"fix-auth","title":"Fix auth middleware","description":"Stop anonymous access to the admin route","type":"bug","status":"open","priority":"high","labels":["security","backend"],"metadata":{"source":"forge"}}}}`)) + })) + defer server.Close() + + s := newTestPrep(t) + s.brainURL = server.URL + + result := s.handlePlanFromIssue(context.Background(), core.NewOptions( + core.Option{Key: "slug", Value: "fix-auth"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(PlanFromIssueOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "Fix auth middleware", output.Issue.Title) + assert.Equal(t, "issue-fix-auth", output.Plan.Slug) + assert.Equal(t, "Stop anonymous access to the admin route", output.Plan.Objective) + assert.NotEmpty(t, output.Path) + assert.True(t, fs.Exists(output.Path)) + + plan, err := readPlan(PlansRoot(), output.Plan.ID) + require.NoError(t, err) + assert.Equal(t, output.Plan.Slug, plan.Slug) + assert.Equal(t, output.Issue.Slug, plan.Context["source_issue_slug"]) +} + +func TestPlanFromIssue_PlanFromIssue_Bad_MissingIdentifier(t *testing.T) { + s := newTestPrep(t) + + result := s.handlePlanFromIssue(context.Background(), core.NewOptions()) + + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "issue slug or id is required") +} + +func TestPlanFromIssue_PlanFromIssue_Ugly_FallsBackToTitleObjective(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + t.Setenv("CORE_AGENT_API_KEY", "secret-token") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"issue":{"id":22,"slug":"refine-logging","title":"Refine logging"}}}`)) + })) + defer server.Close() + + s := newTestPrep(t) + s.brainURL = server.URL + + result := s.handlePlanFromIssue(context.Background(), core.NewOptions( + core.Option{Key: "_arg", Value: "refine-logging"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(PlanFromIssueOutput) + require.True(t, ok) + assert.Equal(t, "Refine logging", output.Plan.Objective) + assert.Equal(t, "issue-refine-logging", output.Plan.Slug) + assert.Equal(t, "Refine logging", output.Plan.Title) +} + +func TestPlanFromIssue_CmdPlanFromIssue_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + t.Setenv("CORE_AGENT_API_KEY", "secret-token") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"issue":{"id":5,"slug":"fix-build","title":"Fix build output","description":"Keep CLI output stable"}}}`)) + })) + defer server.Close() + + s := newTestPrep(t) + s.brainURL = server.URL + + output := captureStdout(t, func() { + result := s.cmdPlanFromIssue(core.NewOptions(core.Option{Key: "_arg", Value: "fix-build"})) + assert.True(t, result.OK) + }) + + assert.Contains(t, output, "created:") + assert.Contains(t, output, "issue:") + assert.Contains(t, output, "path:") +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 75d0808..9abc2a7 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -184,6 +184,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { 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.from.issue", s.handlePlanFromIssue).Description = "Create a plan from a tracked issue" c.Action("plan.check", s.handlePlanCheck).Description = "Check whether a plan or phase is complete" c.Action("plan.archive", s.handlePlanArchive).Description = "Archive an implementation plan by slug" c.Action("plan.delete", s.handlePlanDelete).Description = "Archive an implementation plan by ID" diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index f33f60a..7ac606a 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -445,6 +445,7 @@ func TestPrep_OnStartup_Good_RegistersPlanActions(t *testing.T) { 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.from.issue").Exists()) assert.True(t, c.Action("plan.check").Exists()) assert.True(t, c.Action("plan.archive").Exists()) assert.True(t, c.Action("plan.delete").Exists()) @@ -592,6 +593,7 @@ func TestPrep_OnStartup_Good_RegistersGenerateCommand(t *testing.T) { assert.Contains(t, c.Commands(), "lang/detect") assert.Contains(t, c.Commands(), "lang/list") assert.Contains(t, c.Commands(), "plan-cleanup") + assert.Contains(t, c.Commands(), "plan/from-issue") assert.Contains(t, c.Commands(), "review-queue") assert.Contains(t, c.Commands(), "task") assert.Contains(t, c.Commands(), "task/create")