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 }