- 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
154 lines
5.2 KiB
Go
154 lines
5.2 KiB
Go
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)
|
|
}
|
|
if !changedFilesContainMilestone(in.Scan.ChangedFiles, expected.RoadmapMilestonePath) {
|
|
return notReady(expected, revision, reasonNoMilestoneFile)
|
|
}
|
|
|
|
scanned, ok := scannedIdentityForPath(in.Scan.ScannedMilestones, expected.RoadmapMilestonePath)
|
|
if !ok {
|
|
// The path changed but no parsed provider identity backs it, so the
|
|
// pushed Milestone cannot be tied to the expected work item.
|
|
return notReady(expected, revision, reasonNoScannedIdentity)
|
|
}
|
|
if !expected.Matches(scanned) {
|
|
return notReady(expected, revision, reasonIdentityMismatch)
|
|
}
|
|
|
|
return ProjectionCandidate{
|
|
Identity: expected,
|
|
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/<phase>/milestones/<file>.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
|
|
}
|