- authoring: identity write 도메인 구현 (identity_write.go) - authoring: request/result 도메인 정리 및 테스트 개선 - scheduler: job/river 구조 리팩토링 및 테스트 보완 - gitosync: bridge/scanner 테스트 개선 - roadmap: plane-origin-authoring-roundtrip-sync 마일스톤 업데이트
262 lines
8.8 KiB
Go
262 lines
8.8 KiB
Go
package authoring
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/model"
|
|
"github.com/nomadcode/nomadcode-core/internal/storage"
|
|
"github.com/nomadcode/nomadcode-core/internal/workitempipeline"
|
|
)
|
|
|
|
// pipelinePayload is the actual task.Payload shape produced by
|
|
// workitem.BuildCreateTaskInput. "message" carries the Plane ticket body
|
|
// (comment > description > title), and "work_item" carries the provider
|
|
// identity map (provider, tenant, project, id, state_id, external_url).
|
|
// "prompt" and "instructions" are accepted as a generic override shape.
|
|
type pipelinePayload struct {
|
|
// pipeline shape (set by workitem.BuildCreateTaskInput)
|
|
Message string `json:"message"`
|
|
WorkItem map[string]string `json:"work_item"`
|
|
// generic override shape
|
|
Prompt string `json:"prompt"`
|
|
Instructions string `json:"instructions"`
|
|
}
|
|
|
|
const defaultAuthoringInstructions = "다음 메시지를 바탕으로 마일스톤을 작성해."
|
|
|
|
// PushOnlyCommand is the exact user-facing command sent to the workspace agent
|
|
// as the push-only authoring stage after the first authoring response succeeds.
|
|
const PushOnlyCommand = "변경된 내용에 대해 develop 브런치에 푸시해"
|
|
|
|
// ErrProviderIdentityMissing is returned when a Plane-origin authoring task has
|
|
// checkout metadata but no provider work item id from its external ref, payload,
|
|
// or external metadata.
|
|
var ErrProviderIdentityMissing = errors.New("authoring provider identity missing")
|
|
|
|
// isAuthoringTask returns true when the task is a Plane-origin authoring task
|
|
// that should be routed through the IOP workspace authoring bridge. The criteria
|
|
// are: source=="plane" and task metadata contains a "checkout" key with a
|
|
// non-empty slot path.
|
|
func isAuthoringTask(task storage.Task) (workitempipeline.CheckoutMetadata, bool) {
|
|
if task.Source != "plane" {
|
|
return workitempipeline.CheckoutMetadata{}, false
|
|
}
|
|
if len(task.Metadata) == 0 || string(task.Metadata) == "null" {
|
|
return workitempipeline.CheckoutMetadata{}, false
|
|
}
|
|
var meta struct {
|
|
Checkout *workitempipeline.CheckoutMetadata `json:"checkout"`
|
|
}
|
|
if err := json.Unmarshal(task.Metadata, &meta); err != nil || meta.Checkout == nil {
|
|
return workitempipeline.CheckoutMetadata{}, false
|
|
}
|
|
if strings.TrimSpace(meta.Checkout.SlotPath) == "" {
|
|
return workitempipeline.CheckoutMetadata{}, false
|
|
}
|
|
return *meta.Checkout, true
|
|
}
|
|
|
|
// TaskCheckoutMetadata returns the checkout metadata that makes a task a
|
|
// Plane-origin authoring task. It lets terminal finalizers reuse the same
|
|
// authoring-task boundary as the model input builder.
|
|
func TaskCheckoutMetadata(task storage.Task) (workitempipeline.CheckoutMetadata, bool) {
|
|
return isAuthoringTask(task)
|
|
}
|
|
|
|
type workItemIdentity struct {
|
|
Provider string
|
|
Tenant string
|
|
Project string
|
|
WorkItemID string
|
|
ExternalURL string
|
|
StateID string
|
|
}
|
|
|
|
func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIdentity {
|
|
var ident workItemIdentity
|
|
|
|
ident.Provider = task.Source
|
|
|
|
if task.ExternalProvider != nil && *task.ExternalProvider != "" {
|
|
ident.Provider = *task.ExternalProvider
|
|
}
|
|
if task.ExternalID != nil && *task.ExternalID != "" {
|
|
ident.WorkItemID = *task.ExternalID
|
|
}
|
|
if task.ExternalUrl != nil && *task.ExternalUrl != "" {
|
|
ident.ExternalURL = *task.ExternalUrl
|
|
}
|
|
|
|
if p := strings.TrimSpace(payload.WorkItem["provider"]); p != "" {
|
|
ident.Provider = p
|
|
}
|
|
if id := strings.TrimSpace(payload.WorkItem["id"]); id != "" {
|
|
ident.WorkItemID = id
|
|
}
|
|
if t := strings.TrimSpace(payload.WorkItem["tenant"]); t != "" {
|
|
ident.Tenant = t
|
|
}
|
|
if prj := strings.TrimSpace(payload.WorkItem["project"]); prj != "" {
|
|
ident.Project = prj
|
|
}
|
|
if urlStr := strings.TrimSpace(payload.WorkItem["external_url"]); urlStr != "" {
|
|
ident.ExternalURL = urlStr
|
|
}
|
|
if state := strings.TrimSpace(payload.WorkItem["state_id"]); state != "" {
|
|
ident.StateID = state
|
|
}
|
|
|
|
if len(task.ExternalMetadata) > 0 && string(task.ExternalMetadata) != "null" && string(task.ExternalMetadata) != "{}" {
|
|
var extMeta map[string]any
|
|
if err := json.Unmarshal(task.ExternalMetadata, &extMeta); err == nil {
|
|
if p, ok := extMeta["provider"].(string); ok && p != "" && ident.Provider == "" {
|
|
ident.Provider = p
|
|
}
|
|
if id, ok := extMeta["id"].(string); ok && id != "" && ident.WorkItemID == "" {
|
|
ident.WorkItemID = id
|
|
}
|
|
if id, ok := extMeta["work_item_id"].(string); ok && id != "" && ident.WorkItemID == "" {
|
|
ident.WorkItemID = id
|
|
}
|
|
if t, ok := extMeta["tenant"].(string); ok && t != "" && ident.Tenant == "" {
|
|
ident.Tenant = t
|
|
}
|
|
if p, ok := extMeta["project"].(string); ok && p != "" && ident.Project == "" {
|
|
ident.Project = p
|
|
}
|
|
if s, ok := extMeta["state_id"].(string); ok && s != "" && ident.StateID == "" {
|
|
ident.StateID = s
|
|
}
|
|
if u, ok := extMeta["external_url"].(string); ok && u != "" && ident.ExternalURL == "" {
|
|
ident.ExternalURL = u
|
|
}
|
|
}
|
|
}
|
|
|
|
return ident
|
|
}
|
|
|
|
func validateWorkItemIdentity(ident workItemIdentity) error {
|
|
if strings.TrimSpace(ident.Provider) == "" || strings.TrimSpace(ident.WorkItemID) == "" {
|
|
return ErrProviderIdentityMissing
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildAuthoringPushInput constructs a push-only model.GenerateInput for the
|
|
// second authoring stage. The workspace metadata and provider identity are
|
|
// copied from the original task so the workspace agent can locate the slot and
|
|
// push the authored content to the remote develop branch. Returns false when
|
|
// the task does not qualify as an authoring task.
|
|
func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) {
|
|
input, ok, err := BuildAuthoringPushInputStrict(task)
|
|
if err != nil {
|
|
return model.GenerateInput{}, false
|
|
}
|
|
return input, ok
|
|
}
|
|
|
|
// BuildAuthoringPushInputStrict is the error-returning form used by the
|
|
// scheduler so missing provider identity fails explicitly instead of being
|
|
// hidden behind a task id fallback.
|
|
func BuildAuthoringPushInputStrict(task storage.Task) (model.GenerateInput, bool, error) {
|
|
checkout, ok := isAuthoringTask(task)
|
|
if !ok {
|
|
return model.GenerateInput{}, false, nil
|
|
}
|
|
|
|
var p pipelinePayload
|
|
if len(task.Payload) > 0 && string(task.Payload) != "null" && string(task.Payload) != "{}" {
|
|
_ = json.Unmarshal(task.Payload, &p)
|
|
}
|
|
ident := getWorkItemIdentity(task, p)
|
|
if err := validateWorkItemIdentity(ident); err != nil {
|
|
return model.GenerateInput{}, true, err
|
|
}
|
|
|
|
return model.GenerateInput{
|
|
Input: PushOnlyCommand,
|
|
Metadata: map[string]string{
|
|
"task_id": task.ID,
|
|
"source": task.Source,
|
|
},
|
|
WorkspaceMetadata: &model.WorkspaceMetadata{
|
|
Path: checkout.SlotPath,
|
|
SourceBranch: checkout.SourceBranch,
|
|
Provider: ident.Provider,
|
|
WorkItemID: ident.WorkItemID,
|
|
Tenant: ident.Tenant,
|
|
Project: ident.Project,
|
|
ExternalURL: ident.ExternalURL,
|
|
StateID: ident.StateID,
|
|
},
|
|
}, true, nil
|
|
}
|
|
|
|
// BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput
|
|
// for Plane-origin authoring tasks. Returns false when the task does not qualify
|
|
// as an authoring task (non-Plane source or missing checkout metadata).
|
|
func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) {
|
|
input, ok, err := BuildAuthoringGenerateInputStrict(task)
|
|
if err != nil {
|
|
return model.GenerateInput{}, false
|
|
}
|
|
return input, ok
|
|
}
|
|
|
|
// BuildAuthoringGenerateInputStrict is the error-returning form used by the
|
|
// scheduler so a Plane-origin task with checkout metadata but missing provider
|
|
// work item identity stops with an explicit failure.
|
|
func BuildAuthoringGenerateInputStrict(task storage.Task) (model.GenerateInput, bool, error) {
|
|
checkout, ok := isAuthoringTask(task)
|
|
if !ok {
|
|
return model.GenerateInput{}, false, nil
|
|
}
|
|
|
|
// input priority: prompt override > pipeline message > task title
|
|
input := strings.TrimSpace(task.Title)
|
|
instructions := ""
|
|
|
|
var p pipelinePayload
|
|
if len(task.Payload) > 0 && string(task.Payload) != "null" && string(task.Payload) != "{}" {
|
|
_ = json.Unmarshal(task.Payload, &p)
|
|
}
|
|
|
|
if body := strings.TrimSpace(p.Message); body != "" {
|
|
input = body
|
|
}
|
|
if override := strings.TrimSpace(p.Prompt); override != "" {
|
|
input = override
|
|
}
|
|
instructions = strings.TrimSpace(p.Instructions)
|
|
|
|
ident := getWorkItemIdentity(task, p)
|
|
if err := validateWorkItemIdentity(ident); err != nil {
|
|
return model.GenerateInput{}, true, err
|
|
}
|
|
|
|
if instructions == "" {
|
|
instructions = defaultAuthoringInstructions
|
|
}
|
|
|
|
return model.GenerateInput{
|
|
Input: input,
|
|
Instructions: instructions,
|
|
Metadata: map[string]string{
|
|
"task_id": task.ID,
|
|
"source": task.Source,
|
|
},
|
|
WorkspaceMetadata: &model.WorkspaceMetadata{
|
|
Path: checkout.SlotPath,
|
|
SourceBranch: checkout.SourceBranch,
|
|
Provider: ident.Provider,
|
|
WorkItemID: ident.WorkItemID,
|
|
Tenant: ident.Tenant,
|
|
Project: ident.Project,
|
|
ExternalURL: ident.ExternalURL,
|
|
StateID: ident.StateID,
|
|
},
|
|
}, true, nil
|
|
}
|