173 lines
7 KiB
Go
173 lines
7 KiB
Go
package roadmapsync
|
|
|
|
import "strings"
|
|
|
|
// Step is the provider-neutral creation-cycle step the retry decision resumes
|
|
// from. The values mirror the persisted step ledger so a caller can read the
|
|
// ledger and pass the completed set in without re-mapping strings.
|
|
type Step string
|
|
|
|
const (
|
|
StepDevelopMatched Step = "develop_matched"
|
|
StepOriginalCommentPreserved Step = "original_comment_preserved"
|
|
StepPlaneBodyUpdated Step = "plane_body_updated"
|
|
StepPlaneTodoMoved Step = "plane_todo_moved"
|
|
)
|
|
|
|
// OrderedSteps is the creation-cycle order. A reconcile resumes at the first
|
|
// step not present in the completed set.
|
|
var OrderedSteps = []Step{
|
|
StepDevelopMatched,
|
|
StepOriginalCommentPreserved,
|
|
StepPlaneBodyUpdated,
|
|
StepPlaneTodoMoved,
|
|
}
|
|
|
|
// Action is the next thing a creation cycle should do after reconciling the
|
|
// persisted identity and step ledger against the current trigger.
|
|
type Action string
|
|
|
|
const (
|
|
// ActionResume runs the remaining steps starting at NextStep.
|
|
ActionResume Action = "resume"
|
|
// ActionComplete means every step is already recorded; nothing to do.
|
|
ActionComplete Action = "complete"
|
|
// ActionConflict means the trigger disagrees with the persisted identity
|
|
// (revision mismatch) and a human/upstream decision is required instead of a
|
|
// silent overwrite.
|
|
ActionConflict Action = "conflict"
|
|
// ActionSkipSelfMutation means the trigger was NomadCode's own mutation, so
|
|
// it must not be treated as a new authoring trigger.
|
|
ActionSkipSelfMutation Action = "skip_self_mutation"
|
|
)
|
|
|
|
// ExistingIdentity is the provider-neutral view of a persisted identity row the
|
|
// reconcile needs: the stored identity plus the revisions it was last written
|
|
// with. Found is false when no identity is persisted yet.
|
|
type ExistingIdentity struct {
|
|
Found bool
|
|
Identity Identity
|
|
ProviderRevision string
|
|
RoadmapRevision string
|
|
}
|
|
|
|
// ReconcileInput carries the current trigger and the persisted state to
|
|
// reconcile it against. Existing and CompletedSteps come from the identity map
|
|
// and step ledger; an empty CompletedSteps means a fresh cycle.
|
|
type ReconcileInput struct {
|
|
// Identity is the trigger's provider-neutral identity (the Plane ticket or
|
|
// Milestone path being re-processed).
|
|
Identity Identity
|
|
// ProviderRevision / RoadmapRevision are the revisions the current trigger
|
|
// observed. A mismatch against the persisted revisions is a conflict.
|
|
ProviderRevision string
|
|
RoadmapRevision string
|
|
|
|
// Actor is who caused this trigger. When it equals SelfActor the trigger is
|
|
// NomadCode's own mutation and is not a new authoring trigger.
|
|
Actor string
|
|
SelfActor string
|
|
|
|
Existing ExistingIdentity
|
|
CompletedSteps map[Step]bool
|
|
}
|
|
|
|
// ReconcileResult is the typed decision: what to do next, the resume step when
|
|
// ActionResume, whether an existing identity was reused, and a reason that is
|
|
// always populated so a not-resume decision is never silent.
|
|
type ReconcileResult struct {
|
|
Action Action
|
|
NextStep Step
|
|
IdentityKept bool
|
|
Reason string
|
|
}
|
|
|
|
// Reasons for a reconcile decision. Stable strings so a caller can branch on
|
|
// the cause.
|
|
const (
|
|
reasonInvalidReconcileIdentity = "trigger identity is invalid or incomplete"
|
|
reasonIdentityKeyMismatch = "persisted identity key does not match trigger (provider/tenant/project/work item/path)"
|
|
reasonSelfMutation = "trigger actor is NomadCode self mutation, not a new authoring trigger"
|
|
reasonProviderRevMismatch = "trigger provider revision does not match persisted identity"
|
|
reasonRoadmapRevMismatch = "trigger roadmap revision does not match persisted identity"
|
|
reasonAllStepsComplete = "all creation cycle steps already completed"
|
|
reasonResumeRemaining = "resuming creation cycle at first incomplete step"
|
|
)
|
|
|
|
// ReconcileCreationCycle decides the next action for a (possibly re-processed)
|
|
// creation cycle. It is the idempotency join described by plan 05: it reuses an
|
|
// existing identity when the same Plane ticket or Milestone path is
|
|
// re-processed, refuses to overwrite on a revision mismatch, skips NomadCode's
|
|
// own mutations, and otherwise resumes at the first step not yet in the ledger.
|
|
//
|
|
// The function reads no DB itself; callers supply the persisted identity and
|
|
// completed-step set so the decision stays provider-neutral and unit-testable.
|
|
func ReconcileCreationCycle(in ReconcileInput) ReconcileResult {
|
|
identity, err := in.Identity.Normalize()
|
|
if err != nil {
|
|
return ReconcileResult{Action: ActionConflict, Reason: reasonInvalidReconcileIdentity}
|
|
}
|
|
|
|
// Actor guard: a mutation NomadCode itself made (e.g. the body/title rewrite
|
|
// it just projected) must not be re-interpreted as a fresh user trigger.
|
|
self := strings.TrimSpace(in.SelfActor)
|
|
if self != "" && strings.TrimSpace(in.Actor) == self {
|
|
return ReconcileResult{Action: ActionSkipSelfMutation, Reason: reasonSelfMutation}
|
|
}
|
|
|
|
// Match: when a persisted identity exists it must agree on the minimal key
|
|
// (provider and canonical Milestone id and work item id). A found-but-not-matching
|
|
// row means the trigger disagrees with what is stored (e.g. different provider or
|
|
// different work item id), which is a conflict rather than a silent fresh resume.
|
|
// Changes to path, tenant, project are allowed safe attribute updates.
|
|
if in.Existing.Found && !in.Existing.Identity.Matches(identity) {
|
|
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonIdentityKeyMismatch}
|
|
}
|
|
identityKept := in.Existing.Found
|
|
|
|
// Revision guard: when an identity already exists for this milestone id,
|
|
// the trigger must observe the same revisions, otherwise resuming would
|
|
// silently overwrite work projected from a different develop state.
|
|
if identityKept {
|
|
if mismatch(in.Existing.ProviderRevision, in.ProviderRevision) {
|
|
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonProviderRevMismatch}
|
|
}
|
|
if mismatch(in.Existing.RoadmapRevision, in.RoadmapRevision) {
|
|
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonRoadmapRevMismatch}
|
|
}
|
|
}
|
|
|
|
next, ok := firstIncompleteStep(in.CompletedSteps)
|
|
if !ok {
|
|
return ReconcileResult{Action: ActionComplete, IdentityKept: identityKept, Reason: reasonAllStepsComplete}
|
|
}
|
|
return ReconcileResult{
|
|
Action: ActionResume,
|
|
NextStep: next,
|
|
IdentityKept: identityKept,
|
|
Reason: reasonResumeRemaining,
|
|
}
|
|
}
|
|
|
|
// firstIncompleteStep returns the earliest ordered step not present in the
|
|
// completed set. ok is false when every step is complete.
|
|
func firstIncompleteStep(completed map[Step]bool) (Step, bool) {
|
|
for _, step := range OrderedSteps {
|
|
if !completed[step] {
|
|
return step, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// mismatch reports whether a persisted revision and a trigger revision disagree.
|
|
// Empty values are treated as "unknown" and never conflict, so a first cycle
|
|
// that has not recorded a revision is not blocked.
|
|
func mismatch(persisted, trigger string) bool {
|
|
p := strings.TrimSpace(persisted)
|
|
t := strings.TrimSpace(trigger)
|
|
if p == "" || t == "" {
|
|
return false
|
|
}
|
|
return p != t
|
|
}
|