nomadcode/services/core/internal/roadmapsync/plane_projection.go
toki 32fda2a844 feat: milestone work-item creation sync 및 roadmap sync pipeline 구현
- Plane 프로젝트의 work-item 생성 시 Orchestrator로 동기화
- scheduler에 roadmap sync job 등록 및 river client 지원
- roadmapsycpipeline adapter 패키지 추가
- 관련 테스트 파일 및 브리지 문서 추가
2026-06-14 12:39:31 +09:00

135 lines
5.5 KiB
Go

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
}