- roadmapsync pipeline에 retry 로직 및 개발 일치 검증 로직 추가 - gitosync scanner에 slug 재조정 및 plane 매칭 로직 강화 - roadmap sync identity 관리를 위한 migration(00009) 및 SQL 쿼리 추가 - milestone parser 테스트 및 retry 테스트 보강 - agent-ops 문서(PHASE, SDD, milestones) 갱신 - agent-task archive 및 작업 디렉터리 추가
190 lines
6.1 KiB
Go
190 lines
6.1 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)
|
|
}
|
|
|
|
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/<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
|
|
}
|