nomadcode/services/core/internal/roadmapsync/plane_projection.go
toki 2afa534338 feat: gito events client 및 gito sync bridge 구현, config 확장
- gitoevents: Gito 이벤트 클라이언트 및 이벤트 모델 구현
- gitosync: 브리지, 러너, 스캐너 구현으로 브랜치 이벤트 및 작업 항목 동기화
- config: 새 설정 항목 추가 및 테스트
- roadmapsync: plane projection 수정 및 테스트
- agent-task: G07 관련 계획 및 코드 리뷰 로그 추가
- roadmap: PHASE 및 마일스톤 업데이트
- contracts: Flutter core API 후보 노트 업데이트
2026-06-14 15:31:25 +09:00

139 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, 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 when present, 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 {
body := preserveCommentPrefix
if strings.TrimSpace(in.OriginalBody) != "" {
body += " " + in.OriginalBody
}
return p.AppendComment(ctx, workitem.CommentInput{
Ref: in.Ref,
Body: body,
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.TodoStateID) == "" ||
strings.TrimSpace(in.MilestoneMarkdown) == "" {
return ErrInvalidProjectionInput
}
return nil
}