nomadcode/services/core/internal/workitempipeline/service.go
toki 8803d29554 feat: work item creation sync milestone implementation
- Add Plane API client integration for work item creation
- Update HTTP handlers for work item sync endpoints
- Implement workitem provider and pipeline service updates
- Add test cases for Plane client, handlers, and pipeline
- Update milestone documentation
- Archive task artifacts from 09_creation_trigger_gate and 10+09_plane_draft_authoring
2026-06-14 06:38:50 +09:00

186 lines
6.4 KiB
Go

package workitempipeline
import (
"context"
"encoding/json"
"errors"
"strings"
"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)
}
// 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) {
// 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
}