- Add develop_match.go/handlers for matching Plane tasks to local develop areas - Add identity.go for external ID mapping and lookup - Add milestone_parser.go for parsing agent-roadmap milestone files - Include unit tests for all new modules - Archive completed subtask documents for 03+02_develop_match
161 lines
5.9 KiB
Go
161 lines
5.9 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 + 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"`
|
|
}
|