nomadcode/services/core/internal/workitempipeline/service.go
toki 096efb0a83 feat(workitempipeline): add provision service and archive G06 artifacts
- Add projectsync provision service with tests
- Update HTTP handlers with provision endpoint support
- Update workitempipeline service and tests
- Archive G06 workspace provision artifacts (plan, code review)
- Update G07 cloud code review
2026-06-14 05:13:16 +09:00

143 lines
5.3 KiB
Go

package workitempipeline
import (
"context"
"encoding/json"
"errors"
"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")
type CreateTaskInput struct {
Ref workitem.Ref
StateID string
Comment string
}
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 and reserve a workspace slot before
// touching the provider so a ticket whose project has no active config,
// an invalid config, or no available slot never reaches task creation.
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
}
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
}
item, err := s.reader.FetchWorkItem(ctx, input.Ref)
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,
},
})
}