nomadcode/services/core/internal/workitempipeline/service.go
toki bbb1179194 feat(work-item-pipeline): 작업자 외부 참조 중복 처리 및 고유 인덱스 추가
Tasks 테이블에 external_provider, external_id 기반 UPSERT 로직을 추가하고
GetTaskByExternalRef 쿼리를 신설하여 중복 작업을 효율적으로 처리한다.

외부 참조 고유 인덱스 마이그레이션(00008)을 추가한다.

Workspace slots 쿼리에서 alias를 명시하여 FOR UPDATE SKIP LOCKED 동시성 처리를 개선한다.

Storage, workflow, workitempipeline의 중복 처리 로직을 일관되게 적용하고
Plane 웹훅 테스트를 보강한다.
2026-06-15 14:58:59 +09:00

199 lines
6.8 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)
}
// 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) {
// Idempotency check: see if a task already exists for this external reference.
if input.Ref.Provider != "" && input.Ref.ID != "" {
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
}
}
// Resolve the project sync binding first.
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
}