package roadmapsyncpipeline import ( "context" "errors" "strings" "testing" "github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workitem" ) const ( testMilestonePath = "agent-roadmap/phase/p1/milestones/m1.md" testRevision = "rev-abc" ) // fakeProvider records the order of provider facet calls and can fail a chosen // step so a stop-before-later-step test is possible. type fakeProvider struct { calls []string refCalls []workitem.Ref failOn string failErr error createdInput workitem.CreateInput } func (f *fakeProvider) AppendComment(_ context.Context, input workitem.CommentInput) error { f.calls = append(f.calls, "comment") f.refCalls = append(f.refCalls, input.Ref) if f.failOn == "comment" { return f.failErr } return nil } func (f *fakeProvider) ProjectBody(_ context.Context, input workitem.BodyProjection) error { f.calls = append(f.calls, "body") f.refCalls = append(f.refCalls, input.Ref) if f.failOn == "body" { return f.failErr } return nil } func (f *fakeProvider) ProjectStatus(_ context.Context, input workitem.StatusProjection) error { f.calls = append(f.calls, "status") f.refCalls = append(f.refCalls, input.Ref) if f.failOn == "status" { return f.failErr } return nil } func (f *fakeProvider) CreateWorkItem(_ context.Context, input workitem.CreateInput) (workitem.CreateResult, error) { f.calls = append(f.calls, "create") f.createdInput = input if f.failOn == "create" { return workitem.CreateResult{}, f.failErr } return workitem.CreateResult{ Ref: workitem.Ref{ Provider: input.Provider, Tenant: input.Tenant, Project: input.Project, ID: "CREATED-123", }, }, nil } // fakeStore is an in-memory Store. identity is the single persisted row (id 0 // means "not found"); marked records the steps MarkStepCompleted was called // with, in order. type fakeStore struct { identity db.RoadmapSyncIdentity found bool completed map[storage.RoadmapSyncStep]bool marked []storage.RoadmapSyncStep upserted bool markErr error upsertErr error } func newFakeStore() *fakeStore { return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}} } func (f *fakeStore) GetRoadmapSyncIdentityByMilestoneID(_ context.Context, milestoneID string) (db.RoadmapSyncIdentity, error) { if !f.found || f.identity.RoadmapItemID != milestoneID { return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound } return f.identity, nil } func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { if !f.found { return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound } return f.identity, nil } func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByMilestoneIDParams) (db.RoadmapSyncIdentity, error) { if f.upsertErr != nil { return db.RoadmapSyncIdentity{}, f.upsertErr } f.upserted = true f.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: args.Shape, RoadmapMilestonePath: args.RoadmapMilestonePath, RoadmapItemID: args.RoadmapItemID, Provider: args.Provider, Tenant: args.Tenant, Project: args.Project, WorkItemID: args.WorkItemID, ProviderRevision: args.ProviderRevision, RoadmapRevision: args.RoadmapRevision, } f.found = true return f.identity, nil } func (f *fakeStore) CompletedSteps(_ context.Context, _ int64) (map[storage.RoadmapSyncStep]bool, error) { out := make(map[storage.RoadmapSyncStep]bool, len(f.completed)) for k, v := range f.completed { out[k] = v } return out, nil } func (f *fakeStore) MarkStepCompleted(_ context.Context, _ int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) { if f.markErr != nil { return db.RoadmapSyncStep{}, f.markErr } f.marked = append(f.marked, step) f.completed[step] = true return db.RoadmapSyncStep{Step: string(step)}, nil } // fakeBackfiller records the calls BackfillIdentity received and can fail on // demand, so tests can assert call order (create -> identity upsert -> // develop_matched mark -> backfill -> identity_backfilled mark) and retry // behavior without a real git checkout. type fakeBackfiller struct { calls []BackfillInput err error } func (f *fakeBackfiller) BackfillIdentity(_ context.Context, in BackfillInput) error { f.calls = append(f.calls, in) if f.err != nil { return f.err } return nil } func readyInput() SyncCreationInput { identity := roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", } return SyncCreationInput{ Scan: roadmapsync.ScanResult{ Revision: roadmapsync.Revision{Branch: "develop", Revision: testRevision}, ChangedFiles: []string{testMilestonePath}, ScannedMilestones: []roadmapsync.ScannedMilestone{ {Path: testMilestonePath, Identity: identity}, }, }, Expected: identity, Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "nomad", ID: "NOMAD-1"}, OriginalBody: "

user request

", TodoStateID: "todo-state", MilestoneMarkdown: "# Milestone\n\nbody", ProviderRevision: testRevision, RoadmapRevision: testRevision, ExternalSource: "nomadcode", ExternalID: "sync-1", } } func TestSyncCreationNotReadyDoesNotCallProvider(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() // Break the develop match: no changed milestone file. in.Scan.ChangedFiles = nil res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionNotReady { t.Fatalf("expected not_ready, got %s (%s)", res.Action, res.Reason) } if len(provider.calls) != 0 { t.Fatalf("expected no provider calls, got %v", provider.calls) } if len(store.marked) != 0 || store.upserted { t.Fatalf("expected no ledger marks or upsert, marked=%v upserted=%v", store.marked, store.upserted) } } func TestSyncCreationReadyRunsStepsAndMarksLedger(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "") res, err := svc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason) } wantProviderOrder := []string{"comment", "body", "status"} if !equalStrings(provider.calls, wantProviderOrder) { t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) } wantLedger := []storage.RoadmapSyncStep{ storage.StepDevelopMatched, storage.StepIdentityBackfilled, storage.StepOriginalCommentPreserved, storage.StepPlaneBodyUpdated, storage.StepPlaneTodoMoved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } if res.NextStep != roadmapsync.StepPlaneTodoMoved { t.Fatalf("expected last step plane_todo_moved, got %s", res.NextStep) } } func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) { store := newFakeStore() // Identity already exists with the first two steps recorded. store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepOriginalCommentPreserved] = true provider := &fakeProvider{} svc := NewService(store, provider, "") res, err := svc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason) } if store.upserted { t.Fatal("expected no upsert when identity already exists") } // Comment was already preserved; only body+status should run again. wantProviderOrder := []string{"body", "status"} if !equalStrings(provider.calls, wantProviderOrder) { t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) } wantLedger := []storage.RoadmapSyncStep{ storage.StepIdentityBackfilled, storage.StepPlaneBodyUpdated, storage.StepPlaneTodoMoved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } } // TestSyncCreationExistingIdentityUpdatePartialFailureThenResumes covers S06: // a Milestone whose provider identity already exists is an update candidate // (Plane title/body projection), not a create candidate. When the body/title // update fails mid-cycle, the already-completed comment step is not repeated and // a retry resumes from the completed-step ledger at the body update, then moves // to Todo — no duplicate comment, no re-create. func TestSyncCreationExistingIdentityUpdatePartialFailureThenResumes(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } // Provider identity already backfilled and the original body already // preserved; only the title/body update and the Todo move remain. store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepIdentityBackfilled] = true store.completed[storage.StepOriginalCommentPreserved] = true // First attempt: the title/body update (ProjectBody) fails. bodyErr := errors.New("plane title/body update failed") provider := &fakeProvider{failOn: "body", failErr: bodyErr} svc := NewService(store, provider, "") if _, err := svc.SyncCreation(context.Background(), readyInput()); !errors.Is(err, bodyErr) { t.Fatalf("expected body update error, got %v", err) } // The completed comment step is not repeated; body ran but failed; status // must not run; the body step is not marked because its provider call failed. if !equalStrings(provider.calls, []string{"body"}) { t.Fatalf("first attempt provider calls = %v, want [body]", provider.calls) } if len(store.marked) != 0 { t.Fatalf("no ledger mark expected for a failed body update, got %v", store.marked) } // Retry against the same durable store resumes from the ledger at the body // update and finishes the Todo move without repeating the comment. retryProvider := &fakeProvider{} retrySvc := NewService(store, retryProvider, "") res, err := retrySvc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("retry SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected on retry, got %s (%s)", res.Action, res.Reason) } if !equalStrings(retryProvider.calls, []string{"body", "status"}) { t.Fatalf("retry provider calls = %v, want [body status] (comment not repeated)", retryProvider.calls) } wantLedger := []storage.RoadmapSyncStep{ storage.StepPlaneBodyUpdated, storage.StepPlaneTodoMoved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("retry ledger marks = %v, want %v", store.marked, wantLedger) } if res.NextStep != roadmapsync.StepPlaneTodoMoved { t.Fatalf("expected last step plane_todo_moved, got %s", res.NextStep) } } func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) { store := newFakeStore() bodyErr := errors.New("plane body projection failed") provider := &fakeProvider{failOn: "body", failErr: bodyErr} svc := NewService(store, provider, "") _, err := svc.SyncCreation(context.Background(), readyInput()) if !errors.Is(err, bodyErr) { t.Fatalf("expected body error, got %v", err) } // Comment ran and was marked; body ran but failed; status must not run. wantProviderOrder := []string{"comment", "body"} if !equalStrings(provider.calls, wantProviderOrder) { t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) } // develop_matched + identity_backfilled + original_comment_preserved // recorded; body never marked. wantLedger := []storage.RoadmapSyncStep{ storage.StepDevelopMatched, storage.StepIdentityBackfilled, storage.StepOriginalCommentPreserved, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } } func TestSyncCreationSkipsSelfMutation(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "nomadcode-bot") in := readyInput() in.Actor = "nomadcode-bot" res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionSkipSelfMutation { t.Fatalf("expected skip_self_mutation, got %s (%s)", res.Action, res.Reason) } if len(provider.calls) != 0 { t.Fatalf("expected no provider calls on self mutation, got %v", provider.calls) } if len(store.marked) != 0 { t.Fatalf("expected no ledger marks on self mutation, got %v", store.marked) } } func TestSyncCreationRevisionMismatchConflicts(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", ProviderRevision: nullableString("old-prov-rev"), // mismatch RoadmapRevision: nullableString("old-road-rev"), // mismatch } provider := &fakeProvider{} svc := NewService(store, provider, "") res, err := svc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionConflict { t.Fatalf("expected conflict action, got %s (%s)", res.Action, res.Reason) } if store.upserted { t.Fatal("expected no upsert on revision mismatch conflict") } if len(provider.calls) > 0 { t.Fatal("expected no provider calls on revision mismatch conflict") } if len(store.marked) > 0 { t.Fatal("expected no ledger marks on revision mismatch conflict") } } func TestSyncCreationWorkItemConflictViaMismatch(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-OLD", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionConflict { t.Fatalf("expected conflict action due to existing work item id mismatch, got %s (%s)", res.Action, res.Reason) } if store.upserted { t.Fatal("expected no upsert on work item id mismatch conflict") } if len(provider.calls) > 0 { t.Fatal("expected no provider calls on conflict") } if len(store.marked) > 0 { t.Fatal("expected no ledger marks on conflict") } } // completedIdentityStore seeds a store whose identity row matches readyInput() // exactly and whose ledger already records every creation-cycle step, i.e. a // milestone that is fully synced. It is the persisted state a self-loop // redelivery lands on. func completedIdentityStore() *fakeStore { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepIdentityBackfilled] = true store.completed[storage.StepOriginalCommentPreserved] = true store.completed[storage.StepPlaneBodyUpdated] = true store.completed[storage.StepPlaneTodoMoved] = true return store } // TestSyncCreationSelfLoopRedeliveryCompletesWithoutProviderMutation covers S05: // a Core-authored identity-backfill commit loops back as a fresh branch.updated // webhook whose scanned Milestone now carries the provider identity block. With // the ledger already complete and the markdown identity matching the persisted // row, the cycle must converge to complete/no-op — no create, comment, body, or // status mutation, and no redundant upsert. func TestSyncCreationSelfLoopRedeliveryCompletesWithoutProviderMutation(t *testing.T) { store := completedIdentityStore() provider := &fakeProvider{} svc := NewService(store, provider, "") res, err := svc.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionComplete { t.Fatalf("expected complete on self-loop redelivery, got %s (%s)", res.Action, res.Reason) } if len(provider.calls) != 0 { t.Fatalf("expected no provider mutation on self-loop redelivery, got %v", provider.calls) } if store.upserted { t.Fatal("expected no upsert when the redelivered identity already matches") } if len(store.marked) != 0 { t.Fatalf("expected no new ledger marks on a complete cycle, got %v", store.marked) } } // TestSyncCreationSelfLoopRestartEquivalentUsesDurableLedger covers the // restart-equivalent half of S05: durability comes from the persisted // identity/step ledger, not from any in-process dedup memory. A first Service // runs the cycle to completion; a second, freshly constructed Service (as after // a Core restart) handling the same redelivered input against the same store // must still return complete/no-op with zero provider mutation. func TestSyncCreationSelfLoopRestartEquivalentUsesDurableLedger(t *testing.T) { store := newFakeStore() // First process (pre-restart) runs the full cycle. first := NewService(store, &fakeProvider{}, "") firstRes, err := first.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("first SyncCreation returned error: %v", err) } if firstRes.Action != SyncActionProjected { t.Fatalf("expected first cycle to project, got %s (%s)", firstRes.Action, firstRes.Reason) } // Second process (post-restart): brand new Service and provider, same store. restartProvider := &fakeProvider{} second := NewService(store, restartProvider, "") secondRes, err := second.SyncCreation(context.Background(), readyInput()) if err != nil { t.Fatalf("post-restart SyncCreation returned error: %v", err) } if secondRes.Action != SyncActionComplete { t.Fatalf("expected complete after restart, got %s (%s)", secondRes.Action, secondRes.Reason) } if len(restartProvider.calls) != 0 { t.Fatalf("expected no provider mutation after restart, got %v", restartProvider.calls) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func equalSteps(a, b []storage.RoadmapSyncStep) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} backfiller := &fakeBackfiller{} svc := NewService(store, provider, "") svc.SetBackfiller(backfiller) // 1. Prepare input representing missing active milestone (expected identity without provider details) in := readyInput() in.Expected = roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Tenant: "general", Project: "nomad", } // Also clear Ref as there is no associated work item yet in.Ref = workitem.Ref{} res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason) } if res.NextStep != roadmapsync.StepIdentityBackfilled { t.Fatalf("expected next step identity_backfilled, got %s", res.NextStep) } // Fake creator should be called once with "create" wantCalls := []string{"create"} if !equalStrings(provider.calls, wantCalls) { t.Fatalf("provider calls = %v, want %v", provider.calls, wantCalls) } // Backfiller must run after the identity is upserted, carrying the // created work item id and the develop-relative Milestone path. if len(backfiller.calls) != 1 { t.Fatalf("backfiller calls = %d, want 1", len(backfiller.calls)) } if backfiller.calls[0].Identity.WorkItemID != "CREATED-123" { t.Errorf("backfiller identity WorkItemID = %q, want CREATED-123", backfiller.calls[0].Identity.WorkItemID) } if backfiller.calls[0].MarkdownPath != testMilestonePath { t.Errorf("backfiller MarkdownPath = %q, want %q", backfiller.calls[0].MarkdownPath, testMilestonePath) } if backfiller.calls[0].RoadmapRevision != testRevision { t.Errorf("backfiller RoadmapRevision = %q, want %q", backfiller.calls[0].RoadmapRevision, testRevision) } // Title should be `[milestone-id] title` (in our case extract title should return Milestone from Markdown) expectedTitle := "[m1] Milestone" if provider.createdInput.Title != expectedTitle { t.Errorf("expected created title %q, got %q", expectedTitle, provider.createdInput.Title) } // Ledger should upsert and mark StepDevelopMatched if !store.upserted { t.Fatal("expected identity to be upserted in store") } if store.identity.WorkItemID != "CREATED-123" { t.Errorf("expected upserted WorkItemID to be CREATED-123, got %q", store.identity.WorkItemID) } if store.identity.RoadmapItemID != "m1" { t.Errorf("expected upserted RoadmapItemID to be m1, got %q", store.identity.RoadmapItemID) } wantLedger := []storage.RoadmapSyncStep{ storage.StepDevelopMatched, storage.StepIdentityBackfilled, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } } // TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate reprocesses the // same milestone slug and revision after a prior cycle already persisted the // identity from a successful create but the identity backfill commit/push had // not completed yet. The missing-create predicate keys off `found` (identity // row exists), not off the trigger's empty WorkItemID/Ref, so a duplicate // delivery of the same bridge-produced missing-create job must not call // CreateWorkItem a second time even though the trigger input still looks like // a missing-create job; instead it resumes the identity backfill directly. func TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "acme", Project: "proj-1", WorkItemID: "CREATED-123", } // The ledger already recorded the first cycle's step from the prior create. store.completed[storage.StepDevelopMatched] = true provider := &fakeProvider{} backfiller := &fakeBackfiller{} svc := NewService(store, provider, "") svc.SetBackfiller(backfiller) // Same missing-create shaped trigger as the bridge would re-send: no // WorkItemID/Ref, same milestone slug and develop revision. in := SyncCreationInput{ Scan: roadmapsync.ScanResult{ Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"}, ChangedFiles: []string{testMilestonePath}, ScannedMilestones: []roadmapsync.ScannedMilestone{ {Path: testMilestonePath}, }, }, Expected: roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Tenant: "acme", Project: "proj-1", }, Ref: workitem.Ref{}, OriginalBody: "", TodoStateID: "todo-state", MilestoneMarkdown: "# Milestone\n\nbody", RoadmapRevision: "bbbb", } res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } for _, call := range provider.calls { if call == "create" { t.Fatalf("expected no duplicate CreateWorkItem call, got calls %v", provider.calls) } } if store.upserted { t.Fatal("expected no duplicate upsert for an already-persisted identity") } if res.Action == SyncActionProjected && res.NextStep == roadmapsync.StepDevelopMatched { t.Fatalf("expected no fresh develop_matched projection on duplicate retry, got %+v", res) } // The retry must resume the backfill (not recreate the Plane item) and // mark identity_backfilled once it succeeds. if len(backfiller.calls) != 1 { t.Fatalf("backfiller calls = %d, want 1", len(backfiller.calls)) } if backfiller.calls[0].Identity.WorkItemID != "CREATED-123" { t.Errorf("backfiller identity WorkItemID = %q, want CREATED-123", backfiller.calls[0].Identity.WorkItemID) } if backfiller.calls[0].RoadmapRevision != "bbbb" { t.Errorf("backfiller RoadmapRevision = %q, want bbbb", backfiller.calls[0].RoadmapRevision) } wantLedger := []storage.RoadmapSyncStep{storage.StepIdentityBackfilled} if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } if res.Action != SyncActionProjected || res.NextStep != roadmapsync.StepIdentityBackfilled { t.Fatalf("expected projected/identity_backfilled, got %+v", res) } } // TestSyncCreationBridgeProducedMissingCreate tests the actual bridge-produced // missing-create job shape: Expected.Provider="plane", Tenant and Project set, // WorkItemID empty, Ref.ID empty. This is the path that was broken when the // predicate required Provider == "". func TestSyncCreationBridgeProducedMissingCreate(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} backfiller := &fakeBackfiller{} svc := NewService(store, provider, "") svc.SetBackfiller(backfiller) // Build input exactly as bridge.enqueueDoc does for missing-create: // Provider="plane", Tenant/Project set from config, WorkItemID/Ref empty. in := SyncCreationInput{ Scan: roadmapsync.ScanResult{ Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"}, ChangedFiles: []string{testMilestonePath}, ScannedMilestones: []roadmapsync.ScannedMilestone{ {Path: testMilestonePath}, }, }, Expected: roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "acme", Project: "proj-1", }, Ref: workitem.Ref{}, OriginalBody: "", TodoStateID: "todo-state", MilestoneMarkdown: "# Milestone\n\nbody", RoadmapRevision: "bbbb", } res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason) } // Verify CreateWorkItem was called exactly once. if !equalStrings(provider.calls, []string{"create"}) { t.Fatalf("provider calls = %v, want [create]", provider.calls) } // Verify CreateInput has the right provider/tenant/project. if provider.createdInput.Provider != "plane" { t.Errorf("createdInput.Provider = %q, want %q", provider.createdInput.Provider, "plane") } if provider.createdInput.Tenant != "acme" { t.Errorf("createdInput.Tenant = %q, want %q", provider.createdInput.Tenant, "acme") } if provider.createdInput.Project != "proj-1" { t.Errorf("createdInput.Project = %q, want %q", provider.createdInput.Project, "proj-1") } // Verify identity was upserted with the created work item id. if !store.upserted { t.Fatal("expected identity to be upserted in store") } if store.identity.WorkItemID != "CREATED-123" { t.Errorf("upserted WorkItemID = %q, want CREATED-123", store.identity.WorkItemID) } if store.identity.Provider != "plane" { t.Errorf("upserted Provider = %q, want plane", store.identity.Provider) } if store.identity.Tenant != "acme" { t.Errorf("upserted Tenant = %q, want acme", store.identity.Tenant) } if store.identity.Project != "proj-1" { t.Errorf("upserted Project = %q, want proj-1", store.identity.Project) } // Verify ledger mark. wantLedger := []storage.RoadmapSyncStep{ storage.StepDevelopMatched, storage.StepIdentityBackfilled, } if !equalSteps(store.marked, wantLedger) { t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) } if len(backfiller.calls) != 1 { t.Fatalf("backfiller calls = %d, want 1", len(backfiller.calls)) } if backfiller.calls[0].RoadmapRevision != "bbbb" { t.Errorf("backfiller RoadmapRevision = %q, want bbbb", backfiller.calls[0].RoadmapRevision) } } // TestSyncCreationMissingCreateRequiresTenantAndProject ensures that a // missing-create job without Tenant or Project is rejected before attempting // CreateWorkItem. func TestSyncCreationMissingCreateRequiresTenantAndProject(t *testing.T) { store := newFakeStore() provider := &fakeProvider{} svc := NewService(store, provider, "") in := SyncCreationInput{ Expected: roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Project: "proj-1", // Tenant missing }, Ref: workitem.Ref{}, } _, err := svc.SyncCreation(context.Background(), in) if err == nil { t.Fatal("expected error for missing tenant, got nil") } if !equalStrings(provider.calls, nil) && len(provider.calls) != 0 { t.Fatalf("provider must not be called on validation error, got %v", provider.calls) } } func TestSyncCreationAllowedAttributeUpdateTenantAndProject(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "old-tenant", // differs Project: "old-project", // differs WorkItemID: "NOMAD-1", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionProjected { t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason) } if !store.upserted { t.Fatal("expected upsert for allowed tenant/project update") } if store.identity.Tenant != "general" { t.Errorf("expected Tenant to be updated to general, got %q", store.identity.Tenant) } if store.identity.Project != "nomad" { t.Errorf("expected Project to be updated to nomad, got %q", store.identity.Project) } } func TestSyncCreationWorkItemConflictReturnsConflictAction(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } // Simulate unique constraint violation error on upsert (e.g. work item bound to different milestone) store.upsertErr = storage.ErrRoadmapSyncIdentityConflict provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() // Trigger attribute update by changing the path in.Scan.ChangedFiles = []string{"agent-roadmap/phase/p1/milestones/m-new.md"} in.Scan.ScannedMilestones[0].Path = "agent-roadmap/phase/p1/milestones/m-new.md" in.Expected.RoadmapMilestonePath = "agent-roadmap/phase/p1/milestones/m-new.md" res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionConflict { t.Fatalf("expected conflict action, got %s (%s)", res.Action, res.Reason) } if !strings.Contains(res.Reason, "identity conflict") { t.Errorf("expected conflict reason, got %q", res.Reason) } // The identity conflict must be detected before any provider mutation // (comment/body/status): the existing slug is bound to a different // provider work item, so no create/comment/body/status call should fire. if len(provider.calls) != 0 { t.Fatalf("expected no provider calls before conflict is detected, got %v", provider.calls) } } func TestSyncCreationMissingCreateWorkItemConflictReturnsConflictAction(t *testing.T) { store := newFakeStore() store.upsertErr = storage.ErrRoadmapSyncIdentityConflict provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() in.Expected = roadmapsync.Identity{ Shape: roadmapsync.ShapeMilestone, RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Tenant: "general", Project: "nomad", } in.Ref = workitem.Ref{} res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionConflict { t.Fatalf("expected conflict action, got %s (%s)", res.Action, res.Reason) } } func TestSyncCreationPreservesTriggerRefForProviderProjection(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: testMilestonePath, RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-OLD", ProviderRevision: nullableString(testRevision), RoadmapRevision: nullableString(testRevision), } // Simulate partial resume; say StepDevelopMatched and StepOriginalCommentPreserved are complete store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepOriginalCommentPreserved] = true provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() // Trigger carries WorkItemID = NOMAD-1. Existing work item is NOMAD-OLD but the // scanned milestone identity carries NOMAD-1, so Matches() sees a work item id // mismatch (same slug, different ticket) and returns conflict. // This test exercises that conflict path while verifying no provider mutations run. in.Expected.WorkItemID = "NOMAD-1" in.Ref.ID = "NOMAD-1" in.Scan.ScannedMilestones[0].Identity.WorkItemID = "NOMAD-1" res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionConflict { t.Fatalf("expected conflict action due to work item id mismatch, got %s (%s)", res.Action, res.Reason) } // No provider calls should have run if len(provider.calls) > 0 { t.Fatalf("expected no provider calls, got %v", provider.calls) } // No upsert or ledger marks on conflict if store.upserted { t.Fatal("expected no upsert on work item id mismatch conflict") } if len(store.marked) > 0 { t.Fatal("expected no ledger marks on conflict") } } func TestSyncCreationUpdateAttributesBeforeCompleteNoOp(t *testing.T) { store := newFakeStore() store.found = true store.identity = db.RoadmapSyncIdentity{ ID: 1, Shape: string(roadmapsync.ShapeMilestone), RoadmapMilestonePath: "agent-roadmap/phase/old-path/milestones/m1.md", RoadmapItemID: "m1", Provider: "plane", Tenant: "general", Project: "nomad", WorkItemID: "NOMAD-1", // same as trigger: work item id matches ProviderRevision: nullableString(""), RoadmapRevision: nullableString(""), } // All steps complete store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepIdentityBackfilled] = true store.completed[storage.StepOriginalCommentPreserved] = true store.completed[storage.StepPlaneBodyUpdated] = true store.completed[storage.StepPlaneTodoMoved] = true provider := &fakeProvider{} svc := NewService(store, provider, "") in := readyInput() // Trigger carries updated attributes (different path, work item, and revision) in.Scan.ChangedFiles = []string{testMilestonePath} in.Scan.ScannedMilestones[0].Path = testMilestonePath in.Scan.ScannedMilestones[0].Identity.RoadmapMilestonePath = testMilestonePath in.Expected.RoadmapMilestonePath = testMilestonePath in.Expected.WorkItemID = "NOMAD-1" in.Ref.ID = "NOMAD-1" in.Scan.ScannedMilestones[0].Identity.WorkItemID = "NOMAD-1" in.ProviderRevision = testRevision in.RoadmapRevision = testRevision res, err := svc.SyncCreation(context.Background(), in) if err != nil { t.Fatalf("SyncCreation returned error: %v", err) } if res.Action != SyncActionComplete { t.Fatalf("expected complete action, got %s (%s)", res.Action, res.Reason) } // No provider calls should have run if len(provider.calls) > 0 { t.Fatalf("expected no provider calls, got %v", provider.calls) } // Verify upsert occurred and updated attributes if !store.upserted { t.Fatal("expected upsert even on ActionComplete if attributes changed") } if store.identity.RoadmapMilestonePath != testMilestonePath { t.Errorf("expected upserted path to be %q, got %q", testMilestonePath, store.identity.RoadmapMilestonePath) } if store.identity.WorkItemID != "NOMAD-1" { t.Errorf("expected upserted work item ID to be NOMAD-1, got %q", store.identity.WorkItemID) } if derefString(store.identity.RoadmapRevision) != testRevision { t.Errorf("expected upserted revision to be %q, got %q", testRevision, derefString(store.identity.RoadmapRevision)) } }