package roadmapsync import ( "path" "strings" ) // MatchInput is the input to the develop projection gate: the scan result a // sync layer observed on branches/develop or remote develop, plus the expected // work item identity carried from the originating Plane ticket. The expected // identity supplies the Milestone path and provider/work item id the pushed // change must match. type MatchInput struct { Scan ScanResult Expected Identity } // Reasons explaining a not-ready projection decision. These are stable strings // so a sync layer can branch on the cause without re-deriving it. const ( reasonReady = "pushed develop milestone matched expected identity" reasonInvalidIdentity = "expected identity is invalid or incomplete" reasonNotOnDevelop = "scan revision is not on the develop branch" reasonNoRevision = "scan has no pushed develop revision" reasonNoMilestoneFile = "no changed agent-roadmap milestone file matched the expected milestone path" reasonNoScannedIdentity = "no scanned milestone provider identity for the expected milestone path" reasonIdentityMismatch = "scanned milestone identity does not match the expected work item identity" ) // MatchDevelopMilestone decides whether a pushed/scanned develop change is // projection-ready for the expected Plane work item. It is ready only when: // - the expected identity normalizes (provider + work item id + milestone path), // - the scan revision is anchored on develop (not a slot-local or feature branch), // - the scan carries an actual pushed revision, // - one of the changed files is the expected Milestone file under // agent-roadmap/phase/*/milestones/*.md, and // - the scanned Milestone at that path carries a provider identity that // matches the expected work item identity. // // A slot-local path or push-failure scan supplies no develop revision and so is // never ready. A path-only match whose Milestone has no provider identity, or a // different work item id/provider, is not ready. The reason is always // populated. func MatchDevelopMilestone(in MatchInput) ProjectionCandidate { revision := in.Scan.Revision.Normalize() expected, err := in.Expected.Normalize() if err != nil { return notReady(in.Expected, revision, reasonInvalidIdentity) } if !revision.OnDevelop() { return notReady(expected, revision, reasonNotOnDevelop) } if revision.Revision == "" { return notReady(expected, revision, reasonNoRevision) } var matchedScanned ScannedMilestone var matchedIdentity Identity foundScanned := false for _, sm := range in.Scan.ScannedMilestones { cleanedPath := normalizeRepoPath(sm.Path) if cleanedPath == "" || !isMilestonePath(cleanedPath) { continue } smIdentity, err := sm.Identity.Normalize() if err != nil { continue } if expected.Matches(smIdentity) { matchedScanned = sm matchedScanned.Path = cleanedPath matchedIdentity = smIdentity foundScanned = true break } } if !foundScanned { _, ok := scannedIdentityForPath(in.Scan.ScannedMilestones, expected.RoadmapMilestonePath) if ok { return notReady(expected, revision, reasonIdentityMismatch) } if changedFilesContainMilestone(in.Scan.ChangedFiles, expected.RoadmapMilestonePath) { return notReady(expected, revision, reasonNoScannedIdentity) } return notReady(expected, revision, reasonNoMilestoneFile) } if !changedFilesContainMilestone(in.Scan.ChangedFiles, matchedScanned.Path) { return notReady(expected, revision, reasonNoMilestoneFile) } candidateIdentity := matchedIdentity candidateIdentity.RoadmapMilestonePath = matchedScanned.Path if candidateIdentity.Tenant == "" { candidateIdentity.Tenant = expected.Tenant } if candidateIdentity.Project == "" { candidateIdentity.Project = expected.Project } if candidateIdentity.ParentWorkItemID == "" { candidateIdentity.ParentWorkItemID = expected.ParentWorkItemID } return ProjectionCandidate{ Identity: candidateIdentity, Revision: revision, Ready: true, Reason: reasonReady, } } // scannedIdentityForPath returns the normalized parsed identity of the scanned // Milestone at the expected path. It reports ok=false when no scanned Milestone // matches the path or its identity does not normalize, so a present-but-empty // identity is treated as missing rather than silently matched. func scannedIdentityForPath(scanned []ScannedMilestone, expectedPath string) (Identity, bool) { want := normalizeRepoPath(expectedPath) if want == "" { return Identity{}, false } for _, sm := range scanned { if normalizeRepoPath(sm.Path) != want { continue } identity, err := sm.Identity.Normalize() if err != nil { return Identity{}, false } return identity, true } return Identity{}, false } func notReady(identity Identity, revision Revision, reason string) ProjectionCandidate { return ProjectionCandidate{ Identity: identity, Revision: revision, Ready: false, Reason: reason, } } // changedFilesContainMilestone reports whether any changed file is the expected // Milestone path and sits under the agent-roadmap milestones layout. The // expected path must itself be a milestone-shaped path; matching is exact on // the cleaned repository-relative path so an unrelated edit elsewhere never // counts. func changedFilesContainMilestone(changedFiles []string, expectedPath string) bool { want := normalizeRepoPath(expectedPath) if want == "" || !isMilestonePath(want) { return false } for _, f := range changedFiles { if normalizeRepoPath(f) == want { return true } } return false } // isMilestonePath reports whether a cleaned repo-relative path matches // agent-roadmap/phase//milestones/.md. func isMilestonePath(p string) bool { if !strings.HasSuffix(p, ".md") { return false } parts := strings.Split(p, "/") if len(parts) != 5 { return false } return parts[0] == "agent-roadmap" && parts[1] == "phase" && parts[3] == "milestones" } func normalizeRepoPath(p string) string { trimmed := strings.TrimSpace(p) if trimmed == "" { return "" } cleaned := path.Clean(strings.TrimPrefix(trimmed, "./")) if cleaned == "." || strings.HasPrefix(cleaned, "..") { return "" } return cleaned }