nomadcode/services/core/internal/roadmapsync/identity.go
toki 528e9d6966 feat(roadmapsync): plane origin authoring roundtrip 기능 및 마일스톤 ID 지원
- roadmapsync pipeline에 retry 로직 및 개발 일치 검증 로직 추가
- gitosync scanner에 slug 재조정 및 plane 매칭 로직 강화
- roadmap sync identity 관리를 위한 migration(00009) 및 SQL 쿼리 추가
- milestone parser 테스트 및 retry 테스트 보강
- agent-ops 문서(PHASE, SDD, milestones) 갱신
- agent-task archive 및 작업 디렉터리 추가
2026-06-21 07:59:56 +09:00

198 lines
7 KiB
Go

// Package roadmapsync owns the provider-neutral domain for matching pushed
// agent-roadmap Milestone changes back to the originating provider work item.
// The source of truth is the agent-roadmap on the develop branch (or remote
// develop), never a slot-local edit or an unpushed change.
package roadmapsync
import (
"errors"
"strings"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
var ErrInvalidIdentity = errors.New("invalid roadmap sync identity")
// Shape distinguishes a Milestone-level identity from a Task-level identity, as
// described in the contract sync identity note. The first slice only requires
// the Milestone shape for develop matching; Task is defined for forward
// compatibility with child work item sync.
type Shape string
const (
ShapeMilestone Shape = "milestone"
ShapeTask Shape = "task"
)
func (s Shape) Valid() bool {
switch s {
case ShapeMilestone, ShapeTask:
return true
default:
return false
}
}
// Identity is the provider-neutral shape used to find and compare the same
// Milestone item regardless of which side changed. Field set follows the
// contract sync identity candidate (packages/contracts note 5.2). The minimal
// required match key is provider + canonical Milestone id; path and provider
// work item id are mutable attributes in this slice.
type Identity struct {
Shape Shape `json:"shape"`
RoadmapMilestonePath string `json:"roadmap_milestone_path"`
RoadmapItemID string `json:"roadmap_item_id,omitempty"`
Provider workitem.ProviderID `json:"provider"`
Tenant string `json:"tenant,omitempty"`
Project string `json:"project,omitempty"`
WorkItemID string `json:"work_item_id"`
ParentWorkItemID string `json:"parent_work_item_id,omitempty"`
}
// Normalize trims fields and enforces the minimal required match key: a valid
// shape, a canonical Milestone id, and a provider. Task shapes
// additionally require a parent work item id so a Task is never mistaken for a
// top-level Milestone. Missing required fields are a hard error rather than a
// silently accepted partial identity.
// MilestoneID returns the canonical milestone ID (filename slug) for this identity.
// It checks RoadmapItemID first, then falls back to parsing from RoadmapMilestonePath.
func (i Identity) MilestoneID() string {
if i.RoadmapItemID != "" {
return i.RoadmapItemID
}
if i.RoadmapMilestonePath == "" {
return ""
}
idx := strings.LastIndex(i.RoadmapMilestonePath, "/")
filename := i.RoadmapMilestonePath
if idx != -1 {
filename = i.RoadmapMilestonePath[idx+1:]
}
return strings.TrimSuffix(filename, ".md")
}
func (i Identity) Normalize() (Identity, error) {
shape := Shape(strings.TrimSpace(string(i.Shape)))
if shape == "" {
shape = ShapeMilestone
}
if !shape.Valid() {
return Identity{}, ErrInvalidIdentity
}
normalized := Identity{
Shape: shape,
RoadmapMilestonePath: strings.TrimSpace(i.RoadmapMilestonePath),
RoadmapItemID: strings.TrimSpace(i.RoadmapItemID),
Provider: workitem.ProviderID(strings.TrimSpace(string(i.Provider))),
Tenant: strings.TrimSpace(i.Tenant),
Project: strings.TrimSpace(i.Project),
WorkItemID: strings.TrimSpace(i.WorkItemID),
ParentWorkItemID: strings.TrimSpace(i.ParentWorkItemID),
}
if normalized.Shape == ShapeMilestone && normalized.RoadmapItemID == "" {
normalized.RoadmapItemID = normalized.MilestoneID()
}
if normalized.MilestoneID() == "" || normalized.Provider == "" {
return Identity{}, ErrInvalidIdentity
}
if normalized.Shape == ShapeTask && normalized.ParentWorkItemID == "" {
return Identity{}, ErrInvalidIdentity
}
return normalized, nil
}
// Matches reports whether two identities refer to the same Milestone item. The
// comparison is on the normalized provider + MilestoneID, so
// callers must normalize first; an un-normalizable identity never matches.
func (i Identity) Matches(other Identity) bool {
left, err := i.Normalize()
if err != nil {
return false
}
right, err := other.Normalize()
if err != nil {
return false
}
return left.Provider == right.Provider &&
left.MilestoneID() == right.MilestoneID()
}
// MatchesStrict reports whether two identities refer to the same item using the
// full provider-side identity key: provider, tenant, project, plus MilestoneID.
// Unlike Matches it also compares tenant and project, so a re-process in a different Plane
// workspace/project is never treated as the same identity.
func (i Identity) MatchesStrict(other Identity) bool {
left, err := i.Normalize()
if err != nil {
return false
}
right, err := other.Normalize()
if err != nil {
return false
}
return left.Provider == right.Provider &&
left.Tenant == right.Tenant &&
left.Project == right.Project &&
left.MilestoneID() == right.MilestoneID()
}
// Revision captures the branch and revision context that a scan observed, so a
// projection decision can record what develop state it was based on. Milestone
// sync defaults Branch to develop per the contract note.
type Revision struct {
Branch string `json:"roadmap_branch"`
Revision string `json:"roadmap_revision,omitempty"`
}
// DefaultBranch is the source-of-truth branch for Milestone sync.
const DefaultBranch = "develop"
// Normalize defaults an empty branch to develop and trims the revision.
func (r Revision) Normalize() Revision {
branch := strings.TrimSpace(r.Branch)
if branch == "" {
branch = DefaultBranch
}
return Revision{
Branch: branch,
Revision: strings.TrimSpace(r.Revision),
}
}
// OnDevelop reports whether the revision is anchored on the develop branch.
func (r Revision) OnDevelop() bool {
return r.Normalize().Branch == DefaultBranch
}
// ScannedMilestone pairs a changed Milestone file path with the provider
// identity parsed out of that file. The develop gate matches the parsed
// identity against the expected work item identity, so a path change alone is
// never enough to project.
type ScannedMilestone struct {
Path string `json:"path"`
Identity Identity `json:"identity"`
}
// ScanResult is the input describing what a develop scan observed: the revision
// it looked at, the set of roadmap-relevant files that changed, and the
// Milestone files whose provider identity was parsed. ChangedFiles are
// repository-relative paths.
type ScanResult struct {
Revision Revision `json:"revision"`
ChangedFiles []string `json:"changed_files,omitempty"`
ScannedMilestones []ScannedMilestone `json:"scanned_milestones,omitempty"`
}
// ProjectionCandidate is the typed result of matching a scan against an
// expected work item identity. Ready is only true when a pushed develop
// Milestone change matched the identity; Reason always explains the decision so
// a not-ready result is never silent.
type ProjectionCandidate struct {
Identity Identity `json:"identity"`
Revision Revision `json:"revision"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
}