nomadcode/services/core/internal/roadmapsync/develop_match.go
toki 528e9d6966 feat(roadmapsync): plane origin authoring roundtrip 기능 및 마일스톤 ID 지원
- roadmapsync pipeline에 retry 로직 및 개발 일치 검증 로직 추가
- gitosync scanner에 slug 재조정 및 plane 매칭 로직 강화
- roadmap sync identity 관리를 위한 migration(00009) 및 SQL 쿼리 추가
- milestone parser 테스트 및 retry 테스트 보강
- agent-ops 문서(PHASE, SDD, milestones) 갱신
- agent-task archive 및 작업 디렉터리 추가
2026-06-21 07:59:56 +09:00

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
}