269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
// Package gitosync bridges Gito `branch.updated` wakeup events to the Core
|
|
// Plane-origin Milestone creation sync. It is the layer that turns a wakeup
|
|
// signal into a verified develop scan and an EnqueueRoadmapCreationSync call:
|
|
// the scanner re-verifies the target branch revision from a local develop
|
|
// checkout (never trusting the event's changed_files alone), and the bridge
|
|
// matches each scanned Milestone to its Plane work item and enqueues a job.
|
|
package gitosync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
|
|
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
|
)
|
|
|
|
// ErrNotReady is returned by the scanner when the local develop checkout's
|
|
// remote revision does not yet match the event's `after`, so the pushed state
|
|
// the event announced is not observable locally and nothing should be enqueued.
|
|
var ErrNotReady = errors.New("gitosync: develop revision not ready")
|
|
|
|
// ErrDuplicateActiveSlug is returned when the scan detects duplicate active
|
|
// milestones for the same milestone ID (filename slug).
|
|
var ErrDuplicateActiveSlug = errors.New("gitosync: duplicate active milestone slugs")
|
|
|
|
// CommandRunner runs a git command and returns its stdout. It is the test seam
|
|
// that keeps the scanner unit-testable without a real git checkout; production
|
|
// wraps os/exec. trimming of the trailing newline is the caller's job.
|
|
type CommandRunner interface {
|
|
Run(ctx context.Context, dir string, name string, args ...string) (string, error)
|
|
}
|
|
|
|
// ScannerConfig configures a BranchRevisionScanner. DevelopRepoPath is the
|
|
// local develop checkout the scan runs against; RemoteName is the git remote to
|
|
// fetch and resolve (default origin); Branch is the develop branch name.
|
|
type ScannerConfig struct {
|
|
DevelopRepoPath string
|
|
RemoteName string
|
|
Branch string
|
|
}
|
|
|
|
func (c ScannerConfig) remote() string {
|
|
if strings.TrimSpace(c.RemoteName) == "" {
|
|
return "origin"
|
|
}
|
|
return c.RemoteName
|
|
}
|
|
|
|
func (c ScannerConfig) branch() string {
|
|
if strings.TrimSpace(c.Branch) == "" {
|
|
return gitoevents.DefaultBranch
|
|
}
|
|
return c.Branch
|
|
}
|
|
|
|
// ScannedMilestoneDoc pairs a scanned Milestone with the Markdown the scanner
|
|
// read at the target revision, so the bridge can both match the provider
|
|
// identity and project the develop content without re-reading git.
|
|
type ScannedMilestoneDoc struct {
|
|
Path string
|
|
MilestoneID string
|
|
Markdown string
|
|
HasIdentity bool
|
|
Identity roadmapsync.Identity
|
|
}
|
|
|
|
// ArchivedMilestoneCandidate records an archive Milestone path that shares the
|
|
// same filename-slug lookup space but must not be reconciled as an active item.
|
|
type ArchivedMilestoneCandidate struct {
|
|
Path string
|
|
MilestoneID string
|
|
}
|
|
|
|
// ScanOutput is the verified result of a develop scan: the provider-neutral
|
|
// ScanResult the develop match gate consumes, the per-path active Milestone
|
|
// Markdown the bridge projects, and archive Milestone candidates kept only for
|
|
// future slug lookup/history diagnostics.
|
|
type ScanOutput struct {
|
|
Result roadmapsync.ScanResult
|
|
Docs []ScannedMilestoneDoc
|
|
ArchiveCandidates []ArchivedMilestoneCandidate
|
|
}
|
|
|
|
// BranchRevisionScanner re-verifies a Gito branch event against the local
|
|
// develop checkout and produces a verified ScanResult. It never trusts the
|
|
// event's changed_files as the final set; those are only a wakeup hint.
|
|
type BranchRevisionScanner struct {
|
|
runner CommandRunner
|
|
cfg ScannerConfig
|
|
}
|
|
|
|
// NewBranchRevisionScanner builds a scanner. The runner and a non-empty develop
|
|
// repo path are required.
|
|
func NewBranchRevisionScanner(runner CommandRunner, cfg ScannerConfig) (*BranchRevisionScanner, error) {
|
|
if runner == nil {
|
|
return nil, errors.New("gitosync: command runner is nil")
|
|
}
|
|
if strings.TrimSpace(cfg.DevelopRepoPath) == "" {
|
|
return nil, errors.New("gitosync: develop repo path is required")
|
|
}
|
|
return &BranchRevisionScanner{runner: runner, cfg: cfg}, nil
|
|
}
|
|
|
|
// Scan fetches the remote develop branch, verifies its revision matches the
|
|
// event's `after`, indexes every active Milestone at that revision, and
|
|
// reads/parses each active Milestone. It returns ErrNotReady when the local
|
|
// remote revision does not yet match the event so a caller skips the enqueue
|
|
// instead of acting on stale state. Active Milestone files without a provider
|
|
// identity stay in Docs as missing-create candidates and remain in ChangedFiles
|
|
// so the match gate's reason stays accurate.
|
|
//
|
|
// ev.ChangedFiles (the event's before..after change list) is deliberately not
|
|
// read here: it is only a wakeup-relevance hint the caller may use for
|
|
// logging, and the full active Milestone index from `git ls-tree` at the
|
|
// verified revision is the sole source of truth for what gets scanned/enqueued.
|
|
func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) {
|
|
dir := s.cfg.DevelopRepoPath
|
|
remote := s.cfg.remote()
|
|
branch := s.cfg.branch()
|
|
|
|
if _, err := s.runner.Run(ctx, dir, "git", "fetch", "--prune", remote, branch); err != nil {
|
|
return ScanOutput{}, fmt.Errorf("gitosync: fetch failed: %w", err)
|
|
}
|
|
|
|
remoteRev, err := s.runner.Run(ctx, dir, "git", "rev-parse", remoteRef(remote, branch))
|
|
if err != nil {
|
|
return ScanOutput{}, fmt.Errorf("gitosync: rev-parse failed: %w", err)
|
|
}
|
|
remoteRev = strings.TrimSpace(remoteRev)
|
|
if remoteRev == "" || remoteRev != strings.TrimSpace(ev.After) {
|
|
// The local remote-tracking ref has not caught up to the revision the
|
|
// event announced; acting now would scan stale content.
|
|
return ScanOutput{}, ErrNotReady
|
|
}
|
|
|
|
out, err := s.runner.Run(ctx, dir, "git", "ls-tree", "-r", "--name-only", remoteRev, "--", "agent-roadmap/phase", "agent-roadmap/archive")
|
|
if err != nil {
|
|
return ScanOutput{}, fmt.Errorf("gitosync: active milestone index failed: %w", err)
|
|
}
|
|
|
|
changed := milestonePaths(out)
|
|
archiveCandidates := archiveMilestoneCandidates(out)
|
|
|
|
// Verify that there are no duplicate active milestone slugs.
|
|
slugToPaths := make(map[string][]string)
|
|
for _, path := range changed {
|
|
slug := roadmapsync.MilestoneSlug(path)
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
slugToPaths[slug] = append(slugToPaths[slug], path)
|
|
}
|
|
|
|
var dups []string
|
|
for slug, paths := range slugToPaths {
|
|
if len(paths) > 1 {
|
|
dups = append(dups, fmt.Sprintf("%s (%s)", slug, strings.Join(paths, ", ")))
|
|
}
|
|
}
|
|
if len(dups) > 0 {
|
|
return ScanOutput{}, fmt.Errorf("%w: %s", ErrDuplicateActiveSlug, strings.Join(dups, "; "))
|
|
}
|
|
|
|
result := roadmapsync.ScanResult{
|
|
Revision: roadmapsync.Revision{
|
|
Branch: branch,
|
|
Revision: remoteRev,
|
|
},
|
|
ChangedFiles: changed,
|
|
}
|
|
|
|
var docs []ScannedMilestoneDoc
|
|
for _, path := range changed {
|
|
markdown, err := s.runner.Run(ctx, dir, "git", "show", ev.After+":"+path)
|
|
if err != nil {
|
|
return ScanOutput{}, fmt.Errorf("gitosync: show %q failed: %w", path, err)
|
|
}
|
|
identity, err := roadmapsync.ParseMilestoneIdentity(path, markdown)
|
|
var hasIdentity bool
|
|
var milestoneID string
|
|
if err != nil {
|
|
hasIdentity = false
|
|
milestoneID = roadmapsync.MilestoneSlug(path)
|
|
} else {
|
|
hasIdentity = true
|
|
milestoneID = identity.MilestoneID()
|
|
scanned := roadmapsync.ScannedMilestone{Path: path, Identity: identity}
|
|
result.ScannedMilestones = append(result.ScannedMilestones, scanned)
|
|
}
|
|
docs = append(docs, ScannedMilestoneDoc{
|
|
Path: path,
|
|
MilestoneID: milestoneID,
|
|
Markdown: markdown,
|
|
HasIdentity: hasIdentity,
|
|
Identity: identity,
|
|
})
|
|
}
|
|
|
|
return ScanOutput{Result: result, Docs: docs, ArchiveCandidates: archiveCandidates}, nil
|
|
}
|
|
|
|
func remoteRef(remote, branch string) string {
|
|
return fmt.Sprintf("refs/remotes/%s/%s", remote, branch)
|
|
}
|
|
|
|
// milestonePaths keeps only the lines that look like roadmap Milestone
|
|
// documents, normalizing each to a cleaned repo-relative path.
|
|
func milestonePaths(diffOutput string) []string {
|
|
var out []string
|
|
for _, line := range strings.Split(diffOutput, "\n") {
|
|
path := strings.TrimSpace(line)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if isMilestonePath(path) {
|
|
out = append(out, path)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func archiveMilestoneCandidates(treeOutput string) []ArchivedMilestoneCandidate {
|
|
var out []ArchivedMilestoneCandidate
|
|
for _, line := range strings.Split(treeOutput, "\n") {
|
|
path := strings.TrimSpace(line)
|
|
if path == "" || !isArchiveMilestonePath(path) {
|
|
continue
|
|
}
|
|
id := roadmapsync.MilestoneSlug(path)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
out = append(out, ArchivedMilestoneCandidate{Path: path, MilestoneID: id})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isMilestonePath reports whether a path matches
|
|
// agent-roadmap/phase/<phase>/milestones/<file>.md.
|
|
func isMilestonePath(p string) bool {
|
|
if !strings.HasSuffix(p, ".md") {
|
|
return false
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(p, "./"), "/")
|
|
if len(parts) != 5 {
|
|
return false
|
|
}
|
|
return parts[0] == "agent-roadmap" &&
|
|
parts[1] == "phase" &&
|
|
parts[3] == "milestones"
|
|
}
|
|
|
|
// isArchiveMilestonePath reports whether a path matches an archived Milestone
|
|
// snapshot under agent-roadmap/archive/phase/<phase>/milestones/<file>.md.
|
|
func isArchiveMilestonePath(p string) bool {
|
|
if !strings.HasSuffix(p, ".md") {
|
|
return false
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(p, "./"), "/")
|
|
if len(parts) != 6 {
|
|
return false
|
|
}
|
|
return parts[0] == "agent-roadmap" &&
|
|
parts[1] == "archive" &&
|
|
parts[2] == "phase" &&
|
|
parts[4] == "milestones"
|
|
}
|