nomadcode/services/core/internal/roadmapsyncpipeline/git_backfiller.go
toki 0d619b446c feat(sync): roadmap identity backfill 기능과 git backfiller를 추가한다
roadmap sync 과정에서 origin 프로젝트 간행본의 identity
정보를 채우는 backfill 파이프라인을 구현한다.

- git_backfiller: git 레이블/태그 간행본의 identity 백필
- migration: backfill 완료 단계 테이블 스키마 추가
- pipeline/service: backfill 호출 흐름 연동
- identity_write: backfill 결과 쓰기 통합
2026-07-03 04:19:10 +09:00

270 lines
10 KiB
Go

package roadmapsyncpipeline
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/nomadcode/nomadcode-core/internal/authoring"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
)
// commitMarker tags a backfill commit as Core-authored so a later self-loop
// check can recognize its own commit and skip re-processing it as a fresh
// authoring trigger.
const commitMarker = "NomadCode-Identity-Backfill: true"
// ErrIdentityBackfillMismatch is returned when the target Milestone file
// already carries a provider identity block that disagrees with the identity
// being backfilled, so writing over it would silently rebind history.
var ErrIdentityBackfillMismatch = errors.New("roadmapsyncpipeline: existing provider identity does not match backfill target")
// GitBackfiller is the production Backfiller. It bases every backfill on the
// scan-verified remote develop revision, never a stale local checkout: it
// fetches the remote branch, injects the provider identity block into the
// target Milestone content as read at that revision, stages only that file,
// verifies the staged diff is scoped to the identity block, commits with the
// Core marker on top of the verified revision, and pushes to the configured
// remote/branch. The already-satisfied and mismatch decisions are made against
// the pushed remote state, so a retry after a failed scope-check or push never
// reports success until the identity block actually landed on the remote
// branch.
type GitBackfiller struct {
RepoPath string
RemoteName string
Branch string
}
// NewGitBackfiller builds a GitBackfiller. RepoPath is required; RemoteName
// and Branch default to "origin" and "develop" when empty.
func NewGitBackfiller(repoPath, remoteName, branch string) (*GitBackfiller, error) {
if strings.TrimSpace(repoPath) == "" {
return nil, errors.New("roadmapsyncpipeline: backfiller repo path is required")
}
if strings.TrimSpace(remoteName) == "" {
remoteName = "origin"
}
if strings.TrimSpace(branch) == "" {
branch = "develop"
}
return &GitBackfiller{RepoPath: repoPath, RemoteName: remoteName, Branch: branch}, nil
}
// BackfillIdentity injects the provider identity block into the target
// Milestone as read at the scan-verified remote develop revision, stages only
// that path, verifies the staged diff adds nothing but the identity block,
// commits with the Core marker on top of the verified revision, and pushes to
// develop. It fetches the remote branch first so the already-satisfied and
// mismatch decisions and the commit base all use the pushed remote state, and
// refuses to touch a dirty checkout or a revision that has moved since the scan.
func (b *GitBackfiller) BackfillIdentity(ctx context.Context, in BackfillInput) error {
path := strings.TrimSpace(in.MarkdownPath)
if path == "" {
path = in.Identity.RoadmapMilestonePath
}
if path == "" {
return errors.New("roadmapsyncpipeline: backfill markdown path is required")
}
remoteRef := b.RemoteName + "/" + b.Branch
// Fetch the remote target branch so every decision below observes the pushed
// develop state the scanner verified, never a stale local checkout or an
// unpushed local commit.
if err := b.run(ctx, "fetch", "--prune", b.RemoteName, b.Branch); err != nil {
return err
}
remoteRev, err := b.output(ctx, "rev-parse", remoteRef)
if err != nil {
return err
}
remoteRev = strings.TrimSpace(remoteRev)
if remoteRev == "" {
return fmt.Errorf("roadmapsyncpipeline: empty remote revision for %s", remoteRef)
}
// If the caller passed the scan-verified revision, the remote ref must still
// point at it. Otherwise develop moved since the scan and a fresh event
// should re-drive the sync rather than committing on top of a revision the
// scanner never checked.
if expected := strings.TrimSpace(in.RoadmapRevision); expected != "" && expected != remoteRev {
return fmt.Errorf("roadmapsyncpipeline: backfill target revision moved: %s is at %s, verified scan revision was %s", remoteRef, remoteRev, expected)
}
// Refuse to mutate a dirty checkout: an unrelated in-flight edit must never
// ride along in a Core-authored backfill commit, and resetting the base to
// the remote revision would otherwise silently discard that work.
dirty, err := b.output(ctx, "status", "--porcelain")
if err != nil {
return err
}
if strings.TrimSpace(dirty) != "" {
return fmt.Errorf("roadmapsyncpipeline: backfill checkout is dirty, refusing to mutate: %s", strings.TrimSpace(dirty))
}
// Read the milestone content at the verified remote revision. The
// already-satisfied and mismatch decisions are made against the pushed
// remote state, so a retry after a failed push never reports success until
// the identity block actually landed on the remote branch.
remoteContent, err := b.output(ctx, "show", remoteRev+":"+path)
if err != nil {
return fmt.Errorf("roadmapsyncpipeline: read milestone at %s failed: %w", remoteRev, err)
}
existing, parseErr := roadmapsync.ParseMilestoneIdentity(path, remoteContent)
if parseErr == nil {
if identityMatches(existing, in.Identity) {
// The identity block already exists on the remote develop branch;
// nothing left to commit (e.g. a prior push landed but the ledger
// mark failed).
return nil
}
return ErrIdentityBackfillMismatch
}
if !errors.Is(parseErr, roadmapsync.ErrNoProviderIdentity) {
return fmt.Errorf("roadmapsyncpipeline: parse milestone identity failed: %w", parseErr)
}
// Base the backfill commit on the verified remote revision, not the local
// HEAD, so a checkout that is behind origin/develop still commits on top of
// the revision the scanner verified. The dirty guard above already ensured
// this force-reset discards no uncommitted work.
if err := b.run(ctx, "checkout", "-B", b.Branch, remoteRev); err != nil {
return err
}
block := authoring.RenderProviderIdentityBlock(in.Identity)
fullPath := filepath.Join(b.RepoPath, path)
if err := os.WriteFile(fullPath, []byte(remoteContent+block), 0644); err != nil {
return b.cleanup(ctx, remoteRev, fmt.Errorf("roadmapsyncpipeline: write milestone file failed: %w", err))
}
if err := b.run(ctx, "add", "--", path); err != nil {
return b.cleanup(ctx, remoteRev, err)
}
if err := b.verifyScopedDiff(ctx, path, block); err != nil {
return b.cleanup(ctx, remoteRev, err)
}
commitMsg := fmt.Sprintf("chore(roadmap): backfill provider identity for %s\n\n%s", in.Identity.MilestoneID(), commitMarker)
if err := b.run(ctx, "commit", "-m", commitMsg); err != nil {
return b.cleanup(ctx, remoteRev, err)
}
if err := b.run(ctx, "push", b.RemoteName, "HEAD:"+b.Branch); err != nil {
// Leave the local commit in place and surface the error: the ledger step
// is only marked by the caller after this returns nil, so a failed push
// never advances the ledger, and a retry re-bases on the freshly fetched
// remote revision (checkout -B above) before pushing again.
return err
}
return nil
}
// cleanup restores the checkout to rev after a pre-push failure so a later
// retry re-bases from the remote revision instead of finding a staged or
// half-written identity block it might mistake for progress. The original
// cause is preserved; a cleanup failure is appended rather than hiding it.
func (b *GitBackfiller) cleanup(ctx context.Context, rev string, cause error) error {
if err := b.run(ctx, "reset", "--hard", rev); err != nil {
return fmt.Errorf("%w; additionally backfill cleanup failed: %v", cause, err)
}
return cause
}
// identityMatches reports whether an existing identity block is the same
// provider-side identity as the one being backfilled. It compares the full
// provider key — provider, tenant, project, work item id — plus the canonical
// milestone id, so a block that reuses the same work item under a different
// milestone id is treated as a mismatch rather than silently accepted.
func identityMatches(a, b roadmapsync.Identity) bool {
return a.Provider == b.Provider &&
a.Tenant == b.Tenant &&
a.Project == b.Project &&
a.WorkItemID == b.WorkItemID &&
a.MilestoneID() == b.MilestoneID()
}
// verifyScopedDiff ensures the staged change touches only the target path and
// that the path's diff is a pure addition matching the injected identity
// block, so an unrelated dirty-workspace roadmap edit never rides along in a
// Core-authored backfill commit.
func (b *GitBackfiller) verifyScopedDiff(ctx context.Context, path, block string) error {
staged, err := b.output(ctx, "diff", "--cached", "--name-only")
if err != nil {
return err
}
files := nonEmptyLines(staged)
if len(files) != 1 || files[0] != path {
return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: staged files=%v, want only %q", files, path)
}
diff, err := b.output(ctx, "diff", "--cached", "--", path)
if err != nil {
return err
}
return verifyIdentityOnlyDiff(diff, block)
}
// verifyIdentityOnlyDiff rejects a staged diff that removes any line or adds
// a line outside the rendered identity block.
func verifyIdentityOnlyDiff(diff, block string) error {
blockLines := strings.Split(strings.TrimRight(block, "\n"), "\n")
blockSet := make(map[string]bool, len(blockLines))
for _, l := range blockLines {
blockSet[l] = true
}
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
continue
case strings.HasPrefix(line, "-"):
return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: unexpected removed line %q", line)
case strings.HasPrefix(line, "+"):
added := line[1:]
if !blockSet[added] {
return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: unexpected added line %q", added)
}
}
}
return nil
}
func (b *GitBackfiller) run(ctx context.Context, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = b.RepoPath
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("roadmapsyncpipeline: git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func (b *GitBackfiller) output(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = b.RepoPath
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("roadmapsyncpipeline: git %s failed: %w", strings.Join(args, " "), err)
}
return string(out), nil
}
func nonEmptyLines(s string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
l = strings.TrimSpace(l)
if l != "" {
out = append(out, l)
}
}
return out
}