package roadmapsync import ( "context" "errors" "strings" "github.com/nomadcode/nomadcode-core/internal/workitem" ) // ErrNotReady is returned when the develop projection candidate is not ready, // so a caller never silently projects a Plane Todo for an unmatched or unpushed // Milestone. var ErrNotReady = errors.New("develop projection candidate is not ready") // ErrInvalidProjectionInput is returned when a required projection input // (provider ref, original body, todo state, or milestone content) is missing. var ErrInvalidProjectionInput = errors.New("invalid plane todo projection input") // preserveCommentPrefix opens the comment that preserves the original Plane // body before the develop content replaces it. Kept as a stable prefix so the // preserved request is recognizable in the Plane timeline. const preserveCommentPrefix = "사용자 요청:" // PlaneTodoProvider is the minimal set of provider facets the Todo projection // drives, in execution order: preserve the original body as a comment, replace // title/body, then move the work item to the Todo state. It is satisfied by the // Plane adapter Client and by test fakes. type PlaneTodoProvider interface { workitem.Commenter workitem.BodyProjector workitem.StatusProjector } // ProjectPlaneTodoInput carries everything ProjectPlaneTodo needs without // reaching into provider DTOs: the develop match decision, the original Plane // body to preserve, the Todo state id to move to, and the develop Milestone // markdown to project. Ref is the provider work item the projection targets. type ProjectPlaneTodoInput struct { Candidate ProjectionCandidate Ref workitem.Ref OriginalBody string TodoStateID string // MilestoneMarkdown is the develop Milestone content used to build the new // Plane title/body via FormatPlaneProjection. MilestoneMarkdown string // ExternalSource/ExternalID tag the preservation comment so a re-run can be // recognized as nomadcode-authored rather than a user request. ExternalSource string ExternalID string } // ProjectPlaneTodo runs the Plane Todo projection in the order the Milestone // requires (doc lines 57/102): preserve the original body as a `사용자 요청:` // comment, then replace the title/body with the develop Milestone content, then // move the work item to the Todo state. A failure at any step stops the // sequence so a later step never runs on an unpreserved or unupdated ticket. // // When the develop projection candidate is not ready it returns ErrNotReady // without calling the provider, so a Plane Todo is never produced for an // unmatched or unpushed Milestone. func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneTodoInput) error { if err := ValidateProjectionInput(p, in); err != nil { return err } // 1. Preserve the original Plane body as a comment before anything mutates it. if err := PreserveOriginalComment(ctx, p, in); err != nil { return err } // 2. Replace title/body with the develop Milestone projection. if err := UpdatePlaneBody(ctx, p, in); err != nil { return err } // 3. Move the work item to the Todo state. return MovePlaneTodo(ctx, p, in) } // PreserveOriginalComment is step 1 of the Plane Todo projection: it preserves // the original Plane body as a `사용자 요청:` comment before any later step // overwrites the title/body. It is split out of ProjectPlaneTodo so an // orchestrator can record a per-step ledger mark right after the provider call // succeeds and skip the step on a resume. Inputs are not re-validated here; // ProjectPlaneTodo and the orchestrator validate before calling. func PreserveOriginalComment(ctx context.Context, p workitem.Commenter, in ProjectPlaneTodoInput) error { return p.AppendComment(ctx, workitem.CommentInput{ Ref: in.Ref, Body: preserveCommentPrefix + " " + in.OriginalBody, Format: workitem.CommentFormatHTML, ExternalSource: in.ExternalSource, ExternalID: in.ExternalID, }) } // UpdatePlaneBody is step 2 of the Plane Todo projection: it replaces the work // item title/body with the develop Milestone projection. Split out for the same // per-step ledger reason as PreserveOriginalComment. func UpdatePlaneBody(ctx context.Context, p workitem.BodyProjector, in ProjectPlaneTodoInput) error { projection := FormatPlaneProjection(in.Candidate.Identity.RoadmapMilestonePath, in.MilestoneMarkdown) return p.ProjectBody(ctx, workitem.BodyProjection{ Ref: in.Ref, Title: projection.Title, DescriptionHTML: projection.BodyHTML, }) } // MovePlaneTodo is step 3 of the Plane Todo projection: it moves the work item // to the Todo state. Split out for the same per-step ledger reason as // PreserveOriginalComment. func MovePlaneTodo(ctx context.Context, p workitem.StatusProjector, in ProjectPlaneTodoInput) error { return p.ProjectStatus(ctx, workitem.StatusProjection{ Ref: in.Ref, ProviderState: in.TodoStateID, }) } // ValidateProjectionInput reports the same input guard ProjectPlaneTodo // enforces, so an orchestrator that drives the steps individually rejects the // same missing-input cases before calling any provider facet. func ValidateProjectionInput(p PlaneTodoProvider, in ProjectPlaneTodoInput) error { if !in.Candidate.Ready { return ErrNotReady } if p == nil || strings.TrimSpace(string(in.Ref.Provider)) == "" || strings.TrimSpace(in.Ref.ID) == "" || strings.TrimSpace(in.OriginalBody) == "" || strings.TrimSpace(in.TodoStateID) == "" || strings.TrimSpace(in.MilestoneMarkdown) == "" { return ErrInvalidProjectionInput } return nil }