package gitosync import ( "context" "errors" "testing" "github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/scheduler" "github.com/nomadcode/nomadcode-core/internal/workitem" ) type fakeScanner struct { out ScanOutput err error } func (f fakeScanner) Scan(context.Context, gitoevents.BranchUpdatedEvent) (ScanOutput, error) { return f.out, f.err } type fakeReader struct { item workitem.WorkItem err error refs []workitem.Ref } func (f *fakeReader) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) { f.refs = append(f.refs, ref) if f.err != nil { return workitem.WorkItem{}, f.err } return f.item, nil } type fakeEnqueuer struct { jobs []scheduler.RoadmapCreationSyncJobArgs err error } func (f *fakeEnqueuer) EnqueueRoadmapCreationSync(_ context.Context, args scheduler.RoadmapCreationSyncJobArgs) error { if f.err != nil { return f.err } f.jobs = append(f.jobs, args) return nil } func matchedIdentity() roadmapsync.Identity { id, err := roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: milestonePath, Provider: "plane", Tenant: "acme", Project: "proj-1", WorkItemID: "wi-123", }.Normalize() if err != nil { panic(err) } return id } func matchedScanOutput() ScanOutput { identity := matchedIdentity() scanned := roadmapsync.ScannedMilestone{Path: milestonePath, Identity: identity} return ScanOutput{ Result: roadmapsync.ScanResult{ Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"}, ChangedFiles: []string{milestonePath}, ScannedMilestones: []roadmapsync.ScannedMilestone{scanned}, }, Docs: []ScannedMilestoneDoc{{Milestone: scanned, Markdown: milestoneMarkdownWithIdentity}}, } } func newTestBridge(t *testing.T, scanner Scanner, reader workitem.Reader, enq Enqueuer) *Bridge { t.Helper() b, err := NewBridge(scanner, reader, enq, NewInMemoryRevisionStore(), BridgeConfig{ RepoID: "nomadcode", Branch: "develop", TodoStateID: "state-todo", }, nil) if err != nil { t.Fatalf("NewBridge: %v", err) } return b } func TestBridgeEnqueuesExactJobArgs(t *testing.T) { reader := &fakeReader{item: workitem.WorkItem{ Title: "WI", DescriptionHTML: "

original html

", DescriptionText: "original text", State: "state-backlog", }} enq := &fakeEnqueuer{} bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) if err := bridge.Handle(context.Background(), newScanEvent()); err != nil { t.Fatalf("Handle: %v", err) } if len(enq.jobs) != 1 { t.Fatalf("jobs: got %d, want 1", len(enq.jobs)) } job := enq.jobs[0] if job.Expected.WorkItemID != "wi-123" { t.Fatalf("Expected.WorkItemID: got %q", job.Expected.WorkItemID) } if job.Ref.Provider != "plane" || job.Ref.ID != "wi-123" || job.Ref.Project != "proj-1" { t.Fatalf("Ref: got %+v", job.Ref) } if job.OriginalBody != "

original html

" { t.Fatalf("OriginalBody: got %q, want HTML preferred", job.OriginalBody) } if job.TodoStateID != "state-todo" { t.Fatalf("TodoStateID: got %q", job.TodoStateID) } if job.MilestoneMarkdown != milestoneMarkdownWithIdentity { t.Fatalf("MilestoneMarkdown mismatch") } if job.RoadmapRevision != "bbbb" { t.Fatalf("RoadmapRevision: got %q", job.RoadmapRevision) } // Plane State is mutable and the sync moves it to Todo; it must never become // the ProviderRevision, or a same-develop-revision reprocess would conflict // before the step ledger is consulted. if job.ProviderRevision != "" { t.Fatalf("ProviderRevision: got %q, want empty (mutable Plane state must not be a revision)", job.ProviderRevision) } if job.ExternalSource != "nomadcode" || job.ExternalID != "wi-123" { t.Fatalf("External tags: got source=%q id=%q", job.ExternalSource, job.ExternalID) } } // TestBridgeSameRoadmapRevisionReprocessDoesNotConflictOnProviderState pins the // reconcile contract this review targets: with ProviderRevision left empty, a // re-process of the SAME develop revision after the Plane state has moved (e.g. // backlog -> todo) reconciles cleanly (complete/resume), while a DIFFERENT // roadmap revision is still guarded by RoadmapRevision. func TestBridgeSameRoadmapRevisionReprocessDoesNotConflictOnProviderState(t *testing.T) { identity := matchedIdentity() // Persisted identity from a first cycle: same develop revision, no provider // revision recorded, and the Plane state already moved to todo since. existing := roadmapsync.ExistingIdentity{ Found: true, Identity: identity, ProviderRevision: "", RoadmapRevision: "bbbb", } // Trigger args as the bridge now builds them: empty ProviderRevision, develop // revision in RoadmapRevision. sameRevision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ Identity: identity, ProviderRevision: "", RoadmapRevision: "bbbb", Existing: existing, CompletedSteps: map[roadmapsync.Step]bool{}, }) if sameRevision.Action == roadmapsync.ActionConflict { t.Fatalf("same develop revision reprocess must not conflict, got %q (%s)", sameRevision.Action, sameRevision.Reason) } diffRevision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ Identity: identity, ProviderRevision: "", RoadmapRevision: "cccc", Existing: existing, CompletedSteps: map[roadmapsync.Step]bool{}, }) if diffRevision.Action != roadmapsync.ActionConflict { t.Fatalf("different develop revision reprocess must conflict via RoadmapRevision guard, got %q", diffRevision.Action) } } func TestBridgeOriginalBodyFallsBackToText(t *testing.T) { reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "only text"}} enq := &fakeEnqueuer{} bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) if err := bridge.Handle(context.Background(), newScanEvent()); err != nil { t.Fatalf("Handle: %v", err) } if enq.jobs[0].OriginalBody != "only text" { t.Fatalf("OriginalBody fallback: got %q", enq.jobs[0].OriginalBody) } } func TestBridgeIgnoresWrongRepoBranch(t *testing.T) { enq := &fakeEnqueuer{} bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, &fakeReader{}, enq) ev := newScanEvent() ev.RepoID = "other-repo" if err := bridge.Handle(context.Background(), ev); err != nil { t.Fatalf("Handle: %v", err) } if len(enq.jobs) != 0 { t.Fatalf("wrong repo: want no enqueue, got %d", len(enq.jobs)) } } func TestBridgeDeduplicatesSameRevision(t *testing.T) { enq := &fakeEnqueuer{} reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "body"}} bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) ev := newScanEvent() if err := bridge.Handle(context.Background(), ev); err != nil { t.Fatalf("Handle first: %v", err) } if err := bridge.Handle(context.Background(), ev); err != nil { t.Fatalf("Handle second: %v", err) } if len(enq.jobs) != 1 { t.Fatalf("dedup: want 1 enqueue across two identical events, got %d", len(enq.jobs)) } } func TestBridgeNotReadyDoesNotMarkProcessed(t *testing.T) { enq := &fakeEnqueuer{} store := NewInMemoryRevisionStore() b, err := NewBridge(fakeScanner{err: ErrNotReady}, &fakeReader{}, enq, store, BridgeConfig{ RepoID: "nomadcode", Branch: "develop", TodoStateID: "state-todo", }, nil) if err != nil { t.Fatalf("NewBridge: %v", err) } ev := newScanEvent() if err := b.Handle(context.Background(), ev); !errors.Is(err, ErrNotReady) { t.Fatalf("Handle: got %v, want ErrNotReady", err) } if store.Seen(ev.RepoID, ev.Branch, ev.After) { t.Fatal("not-ready revision must not be marked processed") } } func TestBridgeFetchErrorIsSurfaced(t *testing.T) { enq := &fakeEnqueuer{} reader := &fakeReader{err: errors.New("fetch boom")} bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) if err := bridge.Handle(context.Background(), newScanEvent()); err == nil { t.Fatal("Handle: want fetch error surfaced, got nil") } if len(enq.jobs) != 0 { t.Fatalf("want no enqueue on fetch error, got %d", len(enq.jobs)) } } func TestNewBridgeRequiresTodoStateID(t *testing.T) { _, err := NewBridge(fakeScanner{}, &fakeReader{}, &fakeEnqueuer{}, nil, BridgeConfig{ RepoID: "nomadcode", }, nil) if err == nil { t.Fatal("NewBridge: want error when todo state id is empty") } }