agent/pkg/agentic/plan_from_issue.go
Virgil ea53bdbf8c fix(agentic): normalise issue context keys
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:01:55 +00:00

162 lines
4.9 KiB
Go

// 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
}
tasks := planFromIssueTasks(description)
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_status": issueOutput.Issue.Status,
"source_issue_metadata": issueOutput.Issue.Metadata,
"source_issue_state": issueOutput.Issue.Status,
"source_issue_meta": issueOutput.Issue.Metadata,
},
Phases: []Phase{
{
Number: 1,
Name: phaseTitle,
Description: description,
Criteria: phaseCriteria,
Tasks: tasks,
},
},
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
}
func planFromIssueTasks(body string) []PlanTask {
var tasks []PlanTask
for _, line := range core.Split(body, "\n") {
trimmed := core.Trim(line)
if title, ok := planFromIssueTaskTitle(trimmed); ok {
tasks = append(tasks, PlanTask{Title: title})
}
}
return tasks
}
func planFromIssueTaskTitle(line string) (string, bool) {
for _, prefix := range []string{"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] "} {
if core.HasPrefix(line, prefix) {
title := core.Trim(core.TrimPrefix(line, prefix))
if title != "" {
return title, true
}
return "", false
}
}
return "", false
}
// 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)
}