feat(agentic): add plan from issue command

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 15:42:26 +00:00
parent 28fbe8b988
commit 3c2ab16afb
5 changed files with 271 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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