- Plane Todo projection 및 idempotency/retry 메커니즘 구현 - Roadmap sync identity 및 step 모델/DB 마이그레이션/쿼리 추가 - OpenAI 및 Plane adapter 개선 - roadmaps sync package: plane_format, plane_projection, retry 모듈 추가 - Storage layer: roadmap_sync_identities, roadmap_sync_steps 추가 - agent-task 아카이브 정리
184 lines
5.8 KiB
Go
184 lines
5.8 KiB
Go
package roadmapsync
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
|
)
|
|
|
|
func reconcileIdentity() Identity {
|
|
return Identity{
|
|
Shape: ShapeMilestone,
|
|
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
|
|
Provider: workitem.ProviderID("plane"),
|
|
Tenant: "general",
|
|
Project: "NOMAD",
|
|
WorkItemID: "NOMAD-13",
|
|
}
|
|
}
|
|
|
|
func TestReconcile_SamePlaneTicketReusesIdentity(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
Existing: ExistingIdentity{Found: true, Identity: id},
|
|
CompletedSteps: map[Step]bool{
|
|
StepDevelopMatched: true,
|
|
},
|
|
})
|
|
if res.Action != ActionResume {
|
|
t.Fatalf("action: got %q, want resume (%s)", res.Action, res.Reason)
|
|
}
|
|
if !res.IdentityKept {
|
|
t.Error("expected identity reuse for the same Plane ticket")
|
|
}
|
|
if res.NextStep != StepOriginalCommentPreserved {
|
|
t.Errorf("next step: got %q, want original_comment_preserved", res.NextStep)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_SameMilestonePathReusesIdentity(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
// Existing row found via the Milestone path: same path + provider work item.
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
Existing: ExistingIdentity{Found: true, Identity: id},
|
|
CompletedSteps: map[Step]bool{},
|
|
})
|
|
if res.Action != ActionResume || !res.IdentityKept {
|
|
t.Fatalf("expected resume with identity kept, got %q kept=%v (%s)", res.Action, res.IdentityKept, res.Reason)
|
|
}
|
|
if res.NextStep != StepDevelopMatched {
|
|
t.Errorf("fresh ledger should resume at develop_matched, got %q", res.NextStep)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_ProviderRevisionMismatchConflicts(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
ProviderRevision: "rev-2",
|
|
Existing: ExistingIdentity{
|
|
Found: true, Identity: id, ProviderRevision: "rev-1",
|
|
},
|
|
})
|
|
if res.Action != ActionConflict {
|
|
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
|
|
}
|
|
if !res.IdentityKept {
|
|
t.Error("a conflict on a matched identity should still report the identity as kept")
|
|
}
|
|
}
|
|
|
|
func TestReconcile_RoadmapRevisionMismatchConflicts(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
RoadmapRevision: "abc",
|
|
Existing: ExistingIdentity{
|
|
Found: true, Identity: id, RoadmapRevision: "def",
|
|
},
|
|
})
|
|
if res.Action != ActionConflict {
|
|
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_NomadCodeActorIsNotNewTrigger(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
Actor: "nomadcode",
|
|
SelfActor: "nomadcode",
|
|
Existing: ExistingIdentity{Found: true, Identity: id},
|
|
})
|
|
if res.Action != ActionSkipSelfMutation {
|
|
t.Fatalf("action: got %q, want skip_self_mutation (%s)", res.Action, res.Reason)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_DifferentTenantConflicts(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
existing := id
|
|
existing.Tenant = "other-workspace"
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
Existing: ExistingIdentity{Found: true, Identity: existing},
|
|
CompletedSteps: map[Step]bool{},
|
|
})
|
|
if res.Action != ActionConflict {
|
|
t.Fatalf("tenant mismatch should conflict, got %q (%s)", res.Action, res.Reason)
|
|
}
|
|
if res.IdentityKept {
|
|
t.Error("a non-matching persisted identity must not be reported as kept")
|
|
}
|
|
}
|
|
|
|
func TestReconcile_DifferentProjectConflicts(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
existing := id
|
|
existing.Project = "OTHER"
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
Existing: ExistingIdentity{Found: true, Identity: existing},
|
|
CompletedSteps: map[Step]bool{},
|
|
})
|
|
if res.Action != ActionConflict {
|
|
t.Fatalf("project mismatch should conflict, got %q (%s)", res.Action, res.Reason)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_AllStepsCompleteReturnsComplete(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
Existing: ExistingIdentity{Found: true, Identity: id},
|
|
CompletedSteps: map[Step]bool{
|
|
StepDevelopMatched: true,
|
|
StepOriginalCommentPreserved: true,
|
|
StepPlaneBodyUpdated: true,
|
|
StepPlaneTodoMoved: true,
|
|
},
|
|
})
|
|
if res.Action != ActionComplete {
|
|
t.Fatalf("action: got %q, want complete (%s)", res.Action, res.Reason)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_InvalidIdentityConflicts(t *testing.T) {
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: Identity{Provider: workitem.ProviderID("plane")}, // missing path + work item id
|
|
})
|
|
if res.Action != ActionConflict {
|
|
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_FreshCycleNoExistingResumesAtFirstStep(t *testing.T) {
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: reconcileIdentity(),
|
|
Existing: ExistingIdentity{Found: false},
|
|
CompletedSteps: map[Step]bool{},
|
|
})
|
|
if res.Action != ActionResume || res.NextStep != StepDevelopMatched {
|
|
t.Fatalf("fresh cycle should resume at develop_matched, got %q step=%q", res.Action, res.NextStep)
|
|
}
|
|
if res.IdentityKept {
|
|
t.Error("no existing identity should report IdentityKept=false")
|
|
}
|
|
}
|
|
|
|
func TestReconcile_FirstCycleRevisionUnknownDoesNotConflict(t *testing.T) {
|
|
id := reconcileIdentity()
|
|
// Existing identity has no recorded revision yet; a trigger that carries one
|
|
// must not be blocked.
|
|
res := ReconcileCreationCycle(ReconcileInput{
|
|
Identity: id,
|
|
ProviderRevision: "rev-1",
|
|
Existing: ExistingIdentity{Found: true, Identity: id, ProviderRevision: ""},
|
|
CompletedSteps: map[Step]bool{},
|
|
})
|
|
if res.Action != ActionResume {
|
|
t.Fatalf("unknown persisted revision should not conflict, got %q (%s)", res.Action, res.Reason)
|
|
}
|
|
}
|