// 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") // 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 { Milestone roadmapsync.ScannedMilestone Markdown string } // ScanOutput is the verified result of a develop scan: the provider-neutral // ScanResult the develop match gate consumes, plus the per-path Milestone // Markdown the bridge projects. type ScanOutput struct { Result roadmapsync.ScanResult Docs []ScannedMilestoneDoc } // 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`, diffs the Milestone files changed between `before` and // `after`, and reads/parses each at `after`. 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. Milestone files without a provider // identity are dropped from Docs but kept in the ScanResult ChangedFiles so the // match gate's reason stays accurate. 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 } diffRange := diffRange(ev.Before, ev.After) out, err := s.runner.Run(ctx, dir, "git", "diff", "--name-only", diffRange, "--", "agent-roadmap/phase") if err != nil { return ScanOutput{}, fmt.Errorf("gitosync: diff failed: %w", err) } changed := milestonePaths(out) 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) if err != nil { // No provider identity block: keep the changed path in the result so // the match gate's reason stays accurate, but do not project it. continue } scanned := roadmapsync.ScannedMilestone{Path: path, Identity: identity} result.ScannedMilestones = append(result.ScannedMilestones, scanned) docs = append(docs, ScannedMilestoneDoc{Milestone: scanned, Markdown: markdown}) } return ScanOutput{Result: result, Docs: docs}, nil } func remoteRef(remote, branch string) string { return fmt.Sprintf("refs/remotes/%s/%s", remote, branch) } // diffRange builds the `git diff` range. A missing/zero `before` (a new branch // push) falls back to a single-revision diff so the first push still scans its // Milestone files. func diffRange(before, after string) string { b := strings.TrimSpace(before) if b == "" || isZeroSHA(b) { return strings.TrimSpace(after) } return b + ".." + strings.TrimSpace(after) } func isZeroSHA(s string) bool { if s == "" { return true } for _, r := range s { if r != '0' { return false } } return true } // 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 } // isMilestonePath reports whether a path matches // agent-roadmap/phase//milestones/.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" }