roadmap sync 과정에서 origin 프로젝트 간행본의 identity 정보를 채우는 backfill 파이프라인을 구현한다. - git_backfiller: git 레이블/태그 간행본의 identity 백필 - migration: backfill 완료 단계 테이블 스키마 추가 - pipeline/service: backfill 호출 흐름 연동 - identity_write: backfill 결과 쓰기 통합
270 lines
10 KiB
Go
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
|
|
}
|