// 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 + work item id plus the Milestone path; the // authoring run id is intentionally not a required match key 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 milestone path, a provider, and a work item id. 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. 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.RoadmapMilestonePath == "" || normalized.Provider == "" || normalized.WorkItemID == "" { 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 + work item id + milestone path, 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.WorkItemID == right.WorkItemID && left.RoadmapMilestonePath == right.RoadmapMilestonePath } // 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"` }