491 lines
15 KiB
Go
491 lines
15 KiB
Go
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
|
|
items map[string]workitem.WorkItem
|
|
errs map[string]error
|
|
}
|
|
|
|
func (f *fakeReader) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) {
|
|
f.refs = append(f.refs, ref)
|
|
if f.errs != nil {
|
|
if err, ok := f.errs[ref.ID]; ok {
|
|
return workitem.WorkItem{}, err
|
|
}
|
|
}
|
|
if f.items != nil {
|
|
if item, ok := f.items[ref.ID]; ok {
|
|
return item, nil
|
|
}
|
|
}
|
|
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{{
|
|
Path: milestonePath,
|
|
MilestoneID: "m1",
|
|
Markdown: milestoneMarkdownWithIdentity,
|
|
HasIdentity: true,
|
|
Identity: identity,
|
|
}},
|
|
}
|
|
}
|
|
|
|
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",
|
|
Tenant: "acme",
|
|
Project: "proj-1",
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewBridge: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestBridgeEnqueuesExactJobArgs(t *testing.T) {
|
|
reader := &fakeReader{item: workitem.WorkItem{
|
|
Title: "WI",
|
|
DescriptionHTML: "<p>original html</p>",
|
|
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 != "<p>original html</p>" {
|
|
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 TestBridgeSkipsMissingWorkItemAndContinues(t *testing.T) {
|
|
stale := matchedIdentity()
|
|
stale.WorkItemID = "missing-wi"
|
|
stale.RoadmapMilestonePath = "agent-roadmap/phase/p1/milestones/stale.md"
|
|
|
|
current := matchedIdentity()
|
|
current.WorkItemID = "current-wi"
|
|
current.RoadmapMilestonePath = "agent-roadmap/phase/p1/milestones/current.md"
|
|
|
|
out := ScanOutput{
|
|
Result: roadmapsync.ScanResult{
|
|
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
|
|
ChangedFiles: []string{
|
|
stale.RoadmapMilestonePath,
|
|
current.RoadmapMilestonePath,
|
|
},
|
|
ScannedMilestones: []roadmapsync.ScannedMilestone{
|
|
{Path: stale.RoadmapMilestonePath, Identity: stale},
|
|
{Path: current.RoadmapMilestonePath, Identity: current},
|
|
},
|
|
},
|
|
Docs: []ScannedMilestoneDoc{
|
|
{
|
|
Path: stale.RoadmapMilestonePath,
|
|
MilestoneID: stale.MilestoneID(),
|
|
Markdown: milestoneMarkdownWithIdentity,
|
|
HasIdentity: true,
|
|
Identity: stale,
|
|
},
|
|
{
|
|
Path: current.RoadmapMilestonePath,
|
|
MilestoneID: current.MilestoneID(),
|
|
Markdown: milestoneMarkdownWithIdentity,
|
|
HasIdentity: true,
|
|
Identity: current,
|
|
},
|
|
},
|
|
}
|
|
|
|
reader := &fakeReader{
|
|
errs: map[string]error{
|
|
"missing-wi": workitem.ErrNotFound,
|
|
},
|
|
items: map[string]workitem.WorkItem{
|
|
"current-wi": {
|
|
Title: "Current",
|
|
DescriptionHTML: "<p>current body</p>",
|
|
},
|
|
},
|
|
}
|
|
enq := &fakeEnqueuer{}
|
|
bridge := newTestBridge(t, fakeScanner{out: out}, 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))
|
|
}
|
|
if enq.jobs[0].Expected.WorkItemID != "current-wi" {
|
|
t.Fatalf("enqueued work item: got %q, want current-wi", enq.jobs[0].Expected.WorkItemID)
|
|
}
|
|
if len(reader.refs) != 2 {
|
|
t.Fatalf("FetchWorkItem calls: got %d, want 2", len(reader.refs))
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestBridgeNoDocRevisionIsProcessed(t *testing.T) {
|
|
enq := &fakeEnqueuer{}
|
|
store := NewInMemoryRevisionStore()
|
|
|
|
// ScanOutput with no docs but valid revision (simulating scanner dropping milestone without identity)
|
|
emptyDocsOutput := ScanOutput{
|
|
Result: roadmapsync.ScanResult{
|
|
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
|
|
ChangedFiles: []string{milestonePath},
|
|
},
|
|
Docs: nil,
|
|
}
|
|
|
|
b, err := NewBridge(fakeScanner{out: emptyDocsOutput}, &fakeReader{}, enq, store, BridgeConfig{
|
|
RepoID: "nomadcode",
|
|
Branch: "develop",
|
|
TodoStateID: "state-todo",
|
|
Tenant: "acme",
|
|
Project: "proj-1",
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewBridge: %v", err)
|
|
}
|
|
|
|
ev := newScanEvent()
|
|
if err := b.Handle(context.Background(), ev); err != nil {
|
|
t.Fatalf("Handle: %v", err)
|
|
}
|
|
|
|
// 1. Should not enqueue any jobs
|
|
if len(enq.jobs) != 0 {
|
|
t.Fatalf("expected no jobs enqueued, got %d", len(enq.jobs))
|
|
}
|
|
|
|
// 2. Revision must still be marked as processed/seen
|
|
if !store.Seen(ev.RepoID, ev.Branch, ev.After) {
|
|
t.Fatal("expected revision to be marked processed even when no docs were found")
|
|
}
|
|
}
|
|
|
|
func TestBridgeFillsIdentityTenantProjectFromConfig(t *testing.T) {
|
|
identity := matchedIdentity()
|
|
identity.Tenant = ""
|
|
identity.Project = ""
|
|
out := matchedScanOutput()
|
|
out.Docs[0].Identity = identity
|
|
out.Result.ScannedMilestones[0].Identity = identity
|
|
|
|
reader := &fakeReader{
|
|
item: workitem.WorkItem{
|
|
Title: "WI",
|
|
DescriptionHTML: "<p>original html</p>",
|
|
DescriptionText: "original text",
|
|
},
|
|
}
|
|
enq := &fakeEnqueuer{}
|
|
bridge := newTestBridge(t, fakeScanner{out: out}, 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]
|
|
|
|
// reader should have fetched with config tenant/project
|
|
if len(reader.refs) != 1 {
|
|
t.Fatalf("FetchWorkItem calls: got %d, want 1", len(reader.refs))
|
|
}
|
|
if reader.refs[0].Tenant != "acme" || reader.refs[0].Project != "proj-1" {
|
|
t.Fatalf("Ref tenant/project: got tenant=%q project=%q, want tenant=%q project=%q",
|
|
reader.refs[0].Tenant, reader.refs[0].Project, "acme", "proj-1")
|
|
}
|
|
|
|
// enqueued job ref should have config tenant/project
|
|
if job.Ref.Tenant != "acme" || job.Ref.Project != "proj-1" {
|
|
t.Fatalf("Job Ref tenant/project: got tenant=%q project=%q, want tenant=%q project=%q",
|
|
job.Ref.Tenant, job.Ref.Project, "acme", "proj-1")
|
|
}
|
|
|
|
// enqueued job Expected should have config tenant/project
|
|
if job.Expected.Tenant != "acme" || job.Expected.Project != "proj-1" {
|
|
t.Fatalf("Expected tenant/project: got tenant=%q project=%q, want tenant=%q project=%q",
|
|
job.Expected.Tenant, job.Expected.Project, "acme", "proj-1")
|
|
}
|
|
}
|
|
|
|
func TestBridgeEnqueuesMissingCreateJobArgs(t *testing.T) {
|
|
enq := &fakeEnqueuer{}
|
|
identityLessOutput := ScanOutput{
|
|
Result: roadmapsync.ScanResult{
|
|
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
|
|
ChangedFiles: []string{milestonePath},
|
|
},
|
|
Docs: []ScannedMilestoneDoc{{
|
|
Path: milestonePath,
|
|
MilestoneID: "m1",
|
|
Markdown: milestoneMarkdownNoIdentity,
|
|
HasIdentity: false,
|
|
}},
|
|
}
|
|
bridge := newTestBridge(t, fakeScanner{out: identityLessOutput}, &fakeReader{}, 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.RoadmapItemID != "m1" || job.Expected.RoadmapMilestonePath != milestonePath {
|
|
t.Fatalf("Expected mismatch: %+v", job.Expected)
|
|
}
|
|
if job.Expected.Provider != "plane" || job.Expected.Tenant != "acme" || job.Expected.Project != "proj-1" {
|
|
t.Fatalf("Expected Provider/Tenant/Project: got provider=%q tenant=%q project=%q",
|
|
job.Expected.Provider, job.Expected.Tenant, job.Expected.Project)
|
|
}
|
|
if job.Ref.ID != "" || job.Ref.Provider != "" {
|
|
t.Fatalf("Ref: got %+v, want empty", job.Ref)
|
|
}
|
|
if job.TodoStateID != "state-todo" {
|
|
t.Fatalf("TodoStateID: got %q", job.TodoStateID)
|
|
}
|
|
if job.MilestoneMarkdown != milestoneMarkdownNoIdentity {
|
|
t.Fatalf("MilestoneMarkdown mismatch")
|
|
}
|
|
// RoadmapRevision is intentionally omitted for missing-create: the identity
|
|
// has no completed projection steps yet, so a later reconcile push must not
|
|
// conflict on revision.
|
|
if job.RoadmapRevision != "" {
|
|
t.Fatalf("RoadmapRevision: got %q, want empty for missing-create", job.RoadmapRevision)
|
|
}
|
|
}
|