397 lines
15 KiB
Go
397 lines
15 KiB
Go
package gitosync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/authoring"
|
|
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
|
|
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
|
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
|
)
|
|
|
|
const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md"
|
|
const archivedMilestonePath = "agent-roadmap/archive/phase/p1/milestones/old-m1.md"
|
|
|
|
const milestoneMarkdownWithIdentity = `# Milestone m1
|
|
|
|
## Provider identity
|
|
- provider: plane
|
|
- tenant: acme
|
|
- project: proj-1
|
|
- work item id: wi-123
|
|
`
|
|
|
|
const milestoneMarkdownNoIdentity = `# Milestone m2
|
|
|
|
Just a body, no provider identity block.
|
|
`
|
|
|
|
// fakeRunner returns scripted stdout per (args joined) command and records the
|
|
// ordered sequence of commands it ran.
|
|
type fakeRunner struct {
|
|
responses map[string]string
|
|
errs map[string]error
|
|
calls []string
|
|
}
|
|
|
|
func newFakeRunner() *fakeRunner {
|
|
return &fakeRunner{responses: map[string]string{}, errs: map[string]error{}}
|
|
}
|
|
|
|
func (f *fakeRunner) Run(_ context.Context, _ string, name string, args ...string) (string, error) {
|
|
key := name + " " + strings.Join(args, " ")
|
|
f.calls = append(f.calls, key)
|
|
if err, ok := f.errs[key]; ok {
|
|
return "", err
|
|
}
|
|
return f.responses[key], nil
|
|
}
|
|
|
|
func newScanEvent() gitoevents.BranchUpdatedEvent {
|
|
return gitoevents.BranchUpdatedEvent{
|
|
RepoID: "nomadcode",
|
|
Branch: "develop",
|
|
Before: "aaaa",
|
|
After: "bbbb",
|
|
}
|
|
}
|
|
|
|
func newTestScanner(t *testing.T, runner CommandRunner) *BranchRevisionScanner {
|
|
t.Helper()
|
|
s, err := NewBranchRevisionScanner(runner, ScannerConfig{
|
|
DevelopRepoPath: "/repo",
|
|
RemoteName: "origin",
|
|
Branch: "develop",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewBranchRevisionScanner: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestScanRunsFetchRevParseActiveIndexShowInOrder(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath + "\n"
|
|
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
|
|
wantOrder := []string{
|
|
"git fetch --prune origin develop",
|
|
"git rev-parse refs/remotes/origin/develop",
|
|
"git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive",
|
|
"git show bbbb:" + milestonePath,
|
|
"git show -s --format=%B bbbb",
|
|
}
|
|
if len(runner.calls) != len(wantOrder) {
|
|
t.Fatalf("call count: got %d (%v), want %d", len(runner.calls), runner.calls, len(wantOrder))
|
|
}
|
|
for i, want := range wantOrder {
|
|
if runner.calls[i] != want {
|
|
t.Fatalf("call[%d]: got %q, want %q", i, runner.calls[i], want)
|
|
}
|
|
}
|
|
|
|
if out.Result.Revision.Revision != "bbbb" {
|
|
t.Fatalf("revision: got %q, want bbbb", out.Result.Revision.Revision)
|
|
}
|
|
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath {
|
|
t.Fatalf("scanned milestones: got %+v", out.Result.ScannedMilestones)
|
|
}
|
|
if got := out.Result.ScannedMilestones[0].Identity.WorkItemID; got != "wi-123" {
|
|
t.Fatalf("identity work item id: got %q, want wi-123", got)
|
|
}
|
|
if len(out.Docs) != 1 || out.Docs[0].Markdown != milestoneMarkdownWithIdentity {
|
|
t.Fatalf("docs: got %+v", out.Docs)
|
|
}
|
|
}
|
|
|
|
func TestScanReturnsNotReadyOnRevisionMismatch(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
// Remote develop has not caught up to the event's `after`.
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "cccc\n"
|
|
|
|
_, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if !errors.Is(err, ErrNotReady) {
|
|
t.Fatalf("Scan: got %v, want ErrNotReady", err)
|
|
}
|
|
// It must not diff/show once it knows the revision is stale.
|
|
for _, c := range runner.calls {
|
|
if strings.HasPrefix(c, "git ls-tree") || strings.HasPrefix(c, "git show") {
|
|
t.Fatalf("unexpected command after mismatch: %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanPreservesMilestoneWithoutProviderIdentity(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath
|
|
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownNoIdentity
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
// Kept in ChangedFiles (so the match gate's reason stays accurate) and preserved in Docs for missing-create.
|
|
if len(out.Result.ChangedFiles) != 1 {
|
|
t.Fatalf("changed files: got %v", out.Result.ChangedFiles)
|
|
}
|
|
if len(out.Result.ScannedMilestones) != 0 {
|
|
t.Fatalf("scanned milestones: want none, got %+v", out.Result.ScannedMilestones)
|
|
}
|
|
if len(out.Docs) != 1 {
|
|
t.Fatalf("docs: got %d, want 1", len(out.Docs))
|
|
}
|
|
doc := out.Docs[0]
|
|
if doc.Path != milestonePath || doc.MilestoneID != "m1" || doc.Markdown != milestoneMarkdownNoIdentity || doc.HasIdentity {
|
|
t.Fatalf("doc mismatch: %+v", doc)
|
|
}
|
|
}
|
|
|
|
func TestScanIndexesOnlyActiveMilestonesAndKeepsArchiveCandidates(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] =
|
|
"agent-roadmap/phase/p1/PHASE.md\n" +
|
|
milestonePath + "\n" +
|
|
archivedMilestonePath + "\n"
|
|
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != milestonePath {
|
|
t.Fatalf("changed files: got %v, want only the milestone path", out.Result.ChangedFiles)
|
|
}
|
|
if len(out.ArchiveCandidates) != 1 {
|
|
t.Fatalf("archive candidates: got %+v, want one", out.ArchiveCandidates)
|
|
}
|
|
if out.ArchiveCandidates[0].Path != archivedMilestonePath || out.ArchiveCandidates[0].MilestoneID != "old-m1" {
|
|
t.Fatalf("archive candidate mismatch: %+v", out.ArchiveCandidates[0])
|
|
}
|
|
}
|
|
|
|
func TestScanIndexesRemoteRevisionRegardlessOfBefore(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = ""
|
|
|
|
ev := newScanEvent()
|
|
ev.Before = "0000000000000000000000000000000000000000"
|
|
|
|
if _, err := newTestScanner(t, runner).Scan(context.Background(), ev); err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
for _, c := range runner.calls {
|
|
if strings.HasPrefix(c, "git diff") {
|
|
t.Fatalf("active index must not use diff range, got %q", c)
|
|
}
|
|
if strings.HasPrefix(c, "git ls-tree") && !strings.Contains(c, "bbbb -- ") {
|
|
t.Fatalf("expected ls-tree at remote revision, got %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanRegressionWithIdentityWriter(t *testing.T) {
|
|
// 1. Create a temporary git repo to run the real LocalIdentityWriter on it.
|
|
tmpDir, err := os.MkdirTemp("", "nomadcode-scanner-regression-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// git init
|
|
cmd := exec.Command("git", "init")
|
|
cmd.Dir = tmpDir
|
|
if err := cmd.Run(); err != nil {
|
|
t.Fatalf("failed to git init: %v", err)
|
|
}
|
|
|
|
// git config user.name and user.email (just in case)
|
|
cmd = exec.Command("git", "config", "user.name", "test")
|
|
cmd.Dir = tmpDir
|
|
_ = cmd.Run()
|
|
cmd = exec.Command("git", "config", "user.email", "test@test.com")
|
|
cmd.Dir = tmpDir
|
|
_ = cmd.Run()
|
|
|
|
milestoneRelPath := "agent-roadmap/phase/p1/milestones/m1.md"
|
|
milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "p1", "milestones")
|
|
if err := os.MkdirAll(milestoneDir, 0755); err != nil {
|
|
t.Fatalf("failed to create milestone dir: %v", err)
|
|
}
|
|
|
|
milestoneFullPath := filepath.Join(milestoneDir, "m1.md")
|
|
// write identity-less milestone markdown
|
|
if err := os.WriteFile(milestoneFullPath, []byte(milestoneMarkdownNoIdentity), 0644); err != nil {
|
|
t.Fatalf("failed to write milestone file: %v", err)
|
|
}
|
|
|
|
// 2. Run LocalIdentityWriter to inject identity block.
|
|
writer := authoring.NewLocalIdentityWriter()
|
|
ident := roadmapsync.Identity{
|
|
Provider: "plane",
|
|
WorkItemID: "wi-123",
|
|
Tenant: "acme",
|
|
Project: "proj-1",
|
|
}
|
|
res, err := writer.EnsureProviderIdentity(context.Background(), authoring.EnsureProviderIdentityInput{
|
|
WorkspacePath: tmpDir,
|
|
Identity: ident,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EnsureProviderIdentity failed: %v", err)
|
|
}
|
|
if len(res.InjectedPaths) != 1 || res.InjectedPaths[0] != milestoneRelPath {
|
|
t.Fatalf("unexpected injected paths: %v", res.InjectedPaths)
|
|
}
|
|
|
|
// Read the modified markdown (which now has identity)
|
|
injectedMarkdownBytes, err := os.ReadFile(milestoneFullPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read injected file: %v", err)
|
|
}
|
|
injectedMarkdown := string(injectedMarkdownBytes)
|
|
|
|
// 3. Mock the runner with the injected markdown as the output of `git show`
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestoneRelPath + "\n"
|
|
runner.responses["git show bbbb:"+milestoneRelPath] = injectedMarkdown
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan regression failed: %v", err)
|
|
}
|
|
|
|
// 4. Verify that the scanner now successfully parses it
|
|
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestoneRelPath {
|
|
t.Fatalf("expected 1 scanned milestone, got %+v", out.Result.ScannedMilestones)
|
|
}
|
|
parsedIdent := out.Result.ScannedMilestones[0].Identity
|
|
if parsedIdent.Provider != "plane" || parsedIdent.WorkItemID != "wi-123" || parsedIdent.Tenant != "acme" || parsedIdent.Project != "proj-1" {
|
|
t.Errorf("unexpected parsed identity: %+v", parsedIdent)
|
|
}
|
|
if len(out.Docs) != 1 || out.Docs[0].Markdown != injectedMarkdown {
|
|
t.Fatalf("expected 1 doc with injected markdown, got %+v", out.Docs)
|
|
}
|
|
}
|
|
|
|
// TestScanChangedFilesHintDoesNotGateActiveIndexScan verifies that
|
|
// ev.ChangedFiles (the wakeup hint) is not consulted to decide what gets
|
|
// scanned: even when the hint names an unrelated file, or is empty, the full
|
|
// active Milestone index at the verified revision is the source of truth.
|
|
func TestScanChangedFilesHintDoesNotGateActiveIndexScan(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath
|
|
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
|
|
|
ev := newScanEvent()
|
|
ev.ChangedFiles = []gitoevents.ChangedFile{{Path: "README.md", ChangeType: "modified"}}
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), ev)
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath {
|
|
t.Fatalf("expected active index milestone regardless of unrelated hint, got %+v", out.Result.ScannedMilestones)
|
|
}
|
|
for _, c := range runner.calls {
|
|
if strings.HasPrefix(c, "git diff") {
|
|
t.Fatalf("hint must not be used to build a diff range, got %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly verifies that a tip commit
|
|
// carrying the Core identity-backfill marker sets ScanOutput.SelfAuthored, but
|
|
// the hint does not change what gets scanned: the full active Milestone index at
|
|
// the verified revision is still the source of truth (S05 — the self-loop
|
|
// wakeup is recognized without bypassing the active scan).
|
|
func TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath
|
|
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
|
runner.responses["git show -s --format=%B bbbb"] =
|
|
"chore(roadmap): backfill provider identity for m1\n\n" + roadmapsyncpipeline.CommitMarker + "\n"
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if !out.SelfAuthored {
|
|
t.Fatal("expected SelfAuthored hint for a Core backfill tip commit")
|
|
}
|
|
// The hint must not suppress the active-index scan.
|
|
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath {
|
|
t.Fatalf("self-authored hint must not bypass active scan, got %+v", out.Result.ScannedMilestones)
|
|
}
|
|
if len(out.Docs) != 1 {
|
|
t.Fatalf("docs: got %d, want 1", len(out.Docs))
|
|
}
|
|
}
|
|
|
|
// TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit verifies an ordinary tip
|
|
// commit (no Core marker) leaves the hint false.
|
|
func TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath
|
|
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
|
runner.responses["git show -s --format=%B bbbb"] = "feat: an ordinary user push\n"
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if out.SelfAuthored {
|
|
t.Fatal("expected SelfAuthored to stay false for an ordinary commit")
|
|
}
|
|
}
|
|
|
|
func TestScanReturnsErrorOnDuplicateActiveSlugs(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] =
|
|
"agent-roadmap/phase/p1/milestones/m1.md\n" +
|
|
"agent-roadmap/phase/p2/milestones/m1.md\n"
|
|
|
|
_, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if !errors.Is(err, ErrDuplicateActiveSlug) {
|
|
t.Fatalf("Scan: got %v, want ErrDuplicateActiveSlug", err)
|
|
}
|
|
}
|
|
|
|
func TestScanAllowsSameSlugInActiveAndArchive(t *testing.T) {
|
|
runner := newFakeRunner()
|
|
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
|
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] =
|
|
"agent-roadmap/phase/p1/milestones/m1.md\n" +
|
|
"agent-roadmap/archive/phase/p2/milestones/m1.md\n"
|
|
runner.responses["git show bbbb:agent-roadmap/phase/p1/milestones/m1.md"] = milestoneMarkdownWithIdentity
|
|
|
|
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
|
if err != nil {
|
|
t.Fatalf("Scan: unexpected error: %v", err)
|
|
}
|
|
if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != "agent-roadmap/phase/p1/milestones/m1.md" {
|
|
t.Fatalf("expected 1 active file, got %v", out.Result.ChangedFiles)
|
|
}
|
|
if len(out.ArchiveCandidates) != 1 || out.ArchiveCandidates[0].Path != "agent-roadmap/archive/phase/p2/milestones/m1.md" {
|
|
t.Fatalf("expected 1 archive candidate, got %v", out.ArchiveCandidates)
|
|
}
|
|
}
|