feat(agentic): add plan from issue command
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
28fbe8b988
commit
3c2ab16afb
5 changed files with 271 additions and 0 deletions
|
|
@ -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{
|
||||
|
|
|
|||
134
pkg/agentic/plan_from_issue.go
Normal file
134
pkg/agentic/plan_from_issue.go
Normal 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)
|
||||
}
|
||||
107
pkg/agentic/plan_from_issue_test.go
Normal file
107
pkg/agentic/plan_from_issue_test.go
Normal 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:")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue