package workitempipeline import ( "context" "encoding/json" "errors" "strings" "github.com/jackc/pgx/v5" "github.com/nomadcode/nomadcode-core/internal/projectsync" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" "github.com/nomadcode/nomadcode-core/internal/workitem" ) // ErrProjectSyncNotConfigured is returned when the pipeline has no project sync // binder wired. Plane-origin creation must resolve git/workspace bindings and // reserve a workspace slot from project sync state, so an unconfigured binder // fails explicitly instead of creating a task with an unbound workspace. var ErrProjectSyncNotConfigured = errors.New("project sync resolver is not configured") var ErrCreationTriggerIgnored = errors.New("creation trigger ignored") type CreationTrigger struct { RequiredStateID string RequiredAssigneeID string Actor string SelfActor string } type CreateTaskInput struct { Ref workitem.Ref StateID string Comment string Trigger CreationTrigger } type TaskCreator interface { CreateTask(ctx context.Context, input workflow.CreateTaskInput) (storage.Task, error) GetTaskByExternalRef(ctx context.Context, provider, id string) (storage.Task, error) WithExternalRefLock(ctx context.Context, provider, id string, fn func(ctx context.Context) (storage.Task, error)) (storage.Task, error) } // ProjectBinding is the resolved project sync binding for a work item: the // persisted setting ID plus its normalized config. The pipeline reserves a // workspace slot against SettingID and reads git/workspace fields from Config. type ProjectBinding struct { SettingID int64 Config projectsync.Config } // ProjectBinder resolves the active project sync binding for a work item's // provider/project target and reserves an independent workspace slot for it. // Implementations must key the binding strictly on the normalized // provider/tenant/project so tickets from the same project share one config and // never mix with another project's config, and must reserve one available slot // atomically so concurrent requests never receive the same slot. type ProjectBinder interface { ResolveProjectBinding(ctx context.Context, ref workitem.Ref) (ProjectBinding, error) EnsureProjectWorkspace(ctx context.Context, binding ProjectBinding) error ReserveWorkspaceSlot(ctx context.Context, projectSyncSettingID int64) (projectsync.WorkspaceSlot, error) } // CheckoutMetadata is the stable JSON contract recorded on a created task under // the "checkout" key. git remote and source branch always come from the project // sync config; slot id/index/path come from the reserved slot and the checkout // plan; branch paths come from the provision plan so downstream sync layers can // distinguish the develop scanning base from the slot's independent checkout. type CheckoutMetadata struct { ProjectSyncSettingID int64 `json:"project_sync_setting_id"` WorkspaceID string `json:"workspace_id"` GitRemoteURL string `json:"git_remote_url"` SourceBranch string `json:"source_branch"` ProjectWorkspaceRoot string `json:"project_workspace_root"` MainBranchPath string `json:"main_branch_path"` DevelopBranchPath string `json:"develop_branch_path"` SlotID int64 `json:"slot_id"` SlotIndex int `json:"slot_index"` SlotPath string `json:"slot_path"` } type Service struct { reader workitem.Reader binder ProjectBinder tasks TaskCreator } func New(reader workitem.Reader, binder ProjectBinder, tasks TaskCreator) *Service { return &Service{reader: reader, binder: binder, tasks: tasks} } func (s *Service) CreateTaskFromWorkItem(ctx context.Context, input CreateTaskInput) (storage.Task, error) { if input.Ref.Provider != "" && input.Ref.ID != "" { return s.tasks.WithExternalRefLock(ctx, string(input.Ref.Provider), input.Ref.ID, func(ctx context.Context) (storage.Task, error) { existing, err := s.tasks.GetTaskByExternalRef(ctx, string(input.Ref.Provider), input.Ref.ID) if err == nil { return existing, nil } if !errors.Is(err, pgx.ErrNoRows) { return storage.Task{}, err } return s.createTaskFromWorkItemUnlocked(ctx, input) }) } return s.createTaskFromWorkItemUnlocked(ctx, input) } func (s *Service) createTaskFromWorkItemUnlocked(ctx context.Context, input CreateTaskInput) (storage.Task, error) { if s.binder == nil { return storage.Task{}, ErrProjectSyncNotConfigured } binding, err := s.binder.ResolveProjectBinding(ctx, input.Ref) if err != nil { return storage.Task{}, err } config, err := binding.Config.Normalize() if err != nil { return storage.Task{}, err } // Fetch work item and evaluate creation trigger before creating workspace // side effects (EnsureProjectWorkspace / ReserveWorkspaceSlot). item, err := s.reader.FetchWorkItem(ctx, input.Ref) if err != nil { return storage.Task{}, err } if err := EvaluateCreationTrigger(item, input.Trigger); err != nil { return storage.Task{}, err } // Run workspace provision and slot reservation after passing trigger validation. if err := s.binder.EnsureProjectWorkspace(ctx, binding); err != nil { return storage.Task{}, err } slot, err := s.binder.ReserveWorkspaceSlot(ctx, binding.SettingID) if err != nil { return storage.Task{}, err } plan, err := projectsync.BuildCheckoutPlan(config, slot) if err != nil { return storage.Task{}, err } provision, err := projectsync.BuildProvisionPlan(config) if err != nil { return storage.Task{}, err } createInput, err := workitem.BuildCreateTaskInput(workitem.TaskCreateInput{ Ref: input.Ref, StateID: input.StateID, Comment: input.Comment, }, item) if err != nil { return storage.Task{}, err } createInput.Metadata, err = checkoutTaskMetadata(binding.SettingID, config, slot, plan, provision) if err != nil { return storage.Task{}, err } return s.tasks.CreateTask(ctx, createInput) } func checkoutTaskMetadata(settingID int64, config projectsync.Config, slot projectsync.WorkspaceSlot, plan projectsync.CheckoutPlan, provision projectsync.ProvisionPlan) (json.RawMessage, error) { return json.Marshal(map[string]any{ "checkout": CheckoutMetadata{ ProjectSyncSettingID: settingID, WorkspaceID: config.WorkspaceID, GitRemoteURL: plan.RemoteURL, SourceBranch: plan.SourceBranch, ProjectWorkspaceRoot: plan.ProjectWorkspaceRoot, MainBranchPath: provision.MainBranchPath, DevelopBranchPath: provision.DevelopBranchPath, SlotID: slot.ID, SlotIndex: int(plan.SlotIndex), SlotPath: plan.SlotPath, }, }) } func EvaluateCreationTrigger(item workitem.WorkItem, trigger CreationTrigger) error { actor := strings.TrimSpace(trigger.Actor) selfActor := strings.TrimSpace(trigger.SelfActor) if actor != "" && selfActor != "" && actor == selfActor { return ErrCreationTriggerIgnored } if trigger.RequiredStateID == "" && trigger.RequiredAssigneeID == "" { return nil } if trigger.RequiredStateID != "" { if strings.TrimSpace(item.State) != strings.TrimSpace(trigger.RequiredStateID) { return ErrCreationTriggerIgnored } } if trigger.RequiredAssigneeID != "" { if !item.HasAssignee(trigger.RequiredAssigneeID) { return ErrCreationTriggerIgnored } } return nil }