nomadcode/services/core/internal/gitosync/scanner.go
toki 2afa534338 feat: gito events client 및 gito sync bridge 구현, config 확장
- gitoevents: Gito 이벤트 클라이언트 및 이벤트 모델 구현
- gitosync: 브리지, 러너, 스캐너 구현으로 브랜치 이벤트 및 작업 항목 동기화
- config: 새 설정 항목 추가 및 테스트
- roadmapsync: plane projection 수정 및 테스트
- agent-task: G07 관련 계획 및 코드 리뷰 로그 추가
- roadmap: PHASE 및 마일스톤 업데이트
- contracts: Flutter core API 후보 노트 업데이트
2026-06-14 15:31:25 +09:00

209 lines
7.1 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")
// 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/<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"
}