888 lines
29 KiB
Go
888 lines
29 KiB
Go
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
|
|
}
|
|
|
|
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: "<p>user request</p>",
|
|
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.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.StepPlaneBodyUpdated,
|
|
storage.StepPlaneTodoMoved,
|
|
}
|
|
if !equalSteps(store.marked, wantLedger) {
|
|
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
|
|
}
|
|
}
|
|
|
|
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 + original_comment_preserved recorded; body never marked.
|
|
wantLedger := []storage.RoadmapSyncStep{
|
|
storage.StepDevelopMatched,
|
|
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")
|
|
}
|
|
}
|
|
|
|
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{}
|
|
svc := NewService(store, provider, "")
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
if !equalSteps(store.marked, wantLedger) {
|
|
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
|
|
}
|
|
if res.NextStep != roadmapsync.StepDevelopMatched {
|
|
t.Fatalf("expected next step to be develop_matched, got %s", res.NextStep)
|
|
}
|
|
}
|
|
|
|
// TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate reprocesses the
|
|
// same milestone slug and revision after a prior cycle already persisted the
|
|
// identity from a successful create. 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.
|
|
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{}
|
|
svc := NewService(store, provider, "")
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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{}
|
|
svc := NewService(store, provider, "")
|
|
|
|
// 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,
|
|
}
|
|
if !equalSteps(store.marked, wantLedger) {
|
|
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
|
|
}
|
|
}
|
|
|
|
// 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.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))
|
|
}
|
|
}
|