- Move 03+02 idempotency docs to archive (2026/06) - Add task_store_test.go for storage layer testing - Improve plane_webhook_test.go (handle_null_external_id support) - workflow/service.go: idempotency guard for duplicate webhook events - workitempipeline/service.go: pipeline step error handling fix - workitempipeline/service_test.go: test alignment with pipeline changes
204 lines
7.2 KiB
Go
204 lines
7.2 KiB
Go
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
|
|
}
|