Files
multica/server/internal/handler/issue_workflow.go
700d25eef5 MUL-7420: render the workflow brief once on the server, for the assignee or an observer (#9293)
- issueworkflow.Brief is the only renderer: the claim carries it into every
  run's per-turn prompt; the editor preview and timeline show the same text.
- Workflow handoffs stop writing the brief into handoff_note, so a person's
  note no longer drops the brief and the handoff framing fits again.
- Agents that are not the issue's assignee get an observer brief without
  the step's commands.
- Clearer rule override and narrower wakeup guidance; user text is
  stripped of control characters.
- multica-platform skill documents project workflows.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-16 17:28:45 +08:00

1419 lines
51 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/featureflags"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/issueworkflow"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// Project workflow API (MUL-7420).
//
// Reading workflows is open to any member: every client needs them to render
// a project's board and to filter status choices. Creating, editing and
// deleting workflows, handoffs included, is owner/admin only: a workflow's
// handoffs apply to every project using it. Which workflow a project uses is
// open to owners/admins and to the project's lead.
// requireProjectWorkflowsV1 gates adopting workflows behind the
// project_workflows_v1 flag. Reads, edits of existing workflows and switching
// a project back to Default stay open, so turning the flag off never strands
// a project on a workflow nobody can maintain.
func (h *Handler) requireProjectWorkflowsV1(w http.ResponseWriter, r *http.Request) bool {
if featureflags.ProjectWorkflowsV1Enabled(r.Context(), h.FeatureFlags) {
return true
}
writeFeatureDisabled(w, "project_workflows_disabled", "Project workflows are not enabled")
return false
}
func isWorkspaceAdmin(member db.Member) bool {
return roleAllowed(member.Role, "owner", "admin")
}
// canManageProjectWorkflow reports whether member may change a project's own
// workflow settings: owners and admins, and the member who leads the project.
func canManageProjectWorkflow(member db.Member, project db.Project) bool {
if isWorkspaceAdmin(member) {
return true
}
return project.LeadType.Valid && project.LeadType.String == "member" && project.LeadID == member.UserID
}
type IssueWorkflowHandlerPayload struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
// IssueWorkflowStepPayload is one status of a workflow, in board order, with
// what happens when an issue enters it.
type IssueWorkflowStepPayload struct {
StatusKey string `json:"status_key"`
Handler IssueWorkflowHandlerPayload `json:"handler"`
Instructions string `json:"instructions"`
NextStatusKey string `json:"next_status_key,omitempty"`
BackStatusKey string `json:"back_status_key,omitempty"`
}
type IssueWorkflowResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
Steps []IssueWorkflowStepPayload `json:"steps"`
ProjectIDs []string `json:"project_ids"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// StatusMappingRequirement names a status some issues are on that the target
// workflow does not list. SuggestedStatusKey is the server's default choice.
type StatusMappingRequirement struct {
StatusKey string `json:"status_key"`
IssueCount int64 `json:"issue_count"`
SuggestedStatusKey string `json:"suggested_status_key"`
}
// StatusIssueCount is a status some issues are on that the target workflow
// also lists, so they keep it.
type StatusIssueCount struct {
StatusKey string `json:"status_key"`
IssueCount int64 `json:"issue_count"`
}
type workflowMappingPlan struct {
Required []StatusMappingRequirement `json:"required"`
Unchanged []StatusIssueCount `json:"unchanged"`
TotalIssues int64 `json:"total_issues"`
AffectedIssues int64 `json:"affected_issues"`
}
func stepToPayload(s issueworkflow.Step) IssueWorkflowStepPayload {
return IssueWorkflowStepPayload{
StatusKey: s.StatusKey,
Handler: IssueWorkflowHandlerPayload{Type: s.Handler.Type, ID: s.Handler.ID},
Instructions: s.Instructions,
NextStatusKey: s.NextStatusKey,
BackStatusKey: s.BackStatusKey,
}
}
func payloadToStep(statusKey string, in IssueWorkflowStepPayload) issueworkflow.Step {
return issueworkflow.Step{
StatusKey: statusKey,
Handler: issueworkflow.Handler{Type: in.Handler.Type, ID: in.Handler.ID},
Instructions: in.Instructions,
NextStatusKey: in.NextStatusKey,
BackStatusKey: in.BackStatusKey,
}
}
func issueWorkflowToResponse(row db.IssueWorkflow, def issueworkflow.Definition, projectIDs []string) IssueWorkflowResponse {
if projectIDs == nil {
projectIDs = []string{}
}
steps := make([]IssueWorkflowStepPayload, len(def.Steps))
for i, s := range def.Steps {
steps[i] = stepToPayload(s)
}
return IssueWorkflowResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
Name: row.Name,
Description: row.Description,
IsDefault: row.IsDefault,
Steps: steps,
ProjectIDs: projectIDs,
CreatedAt: timestampToString(row.CreatedAt),
UpdatedAt: timestampToString(row.UpdatedAt),
}
}
// projectIDsByWorkflow groups the workspace's projects by the workflow row
// they use, so a list response can say where each workflow applies. Projects
// without a workflow, or pointing at a deleted one, count for Default.
func (h *Handler) projectIDsByWorkflow(ctx context.Context, wsUUID pgtype.UUID, rows []db.IssueWorkflow) (map[string][]string, error) {
projects, err := h.Queries.ListProjects(ctx, db.ListProjectsParams{WorkspaceID: wsUUID})
if err != nil {
return nil, err
}
known := map[string]bool{}
defaultID := ""
for _, row := range rows {
id := uuidToString(row.ID)
known[id] = true
if row.IsDefault {
defaultID = id
}
}
out := map[string][]string{}
for _, p := range projects {
key := uuidToString(p.WorkflowID)
if !p.WorkflowID.Valid || !known[key] {
key = defaultID
}
if key != "" {
out[key] = append(out[key], uuidToString(p.ID))
}
}
return out, nil
}
// workflowStatusCatalog loads the workspace catalog keyed by status key,
// including archived rows so validation can say why a key is unusable.
func (h *Handler) workflowStatusCatalog(ctx context.Context, q *db.Queries, wsUUID pgtype.UUID) (map[string]issueworkflow.CatalogEntry, error) {
if err := issuestatus.Ensure(ctx, q, wsUUID); err != nil {
return nil, err
}
rows, err := q.ListIssueStatusEntries(ctx, db.ListIssueStatusEntriesParams{WorkspaceID: wsUUID, IncludeArchived: true})
if err != nil {
return nil, err
}
out := make(map[string]issueworkflow.CatalogEntry, len(rows))
for _, row := range rows {
// The lifecycle category, not the legacy wire value: a switch maps
// in_progress onto in_review because both are "started".
category, _ := issuestatus.ParseCategory(row.Category)
out[row.Key] = issueworkflow.CatalogEntry{
Key: row.Key,
Name: row.Name,
Category: category,
Archived: row.ArchivedAt.Valid,
}
}
return out, nil
}
func (h *Handler) ListIssueWorkflows(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
// Default is stored the first time workflows are read with the feature
// on, so every client sees the same Default from then on.
if featureflags.ProjectWorkflowsV1Enabled(r.Context(), h.FeatureFlags) {
if _, err := issueworkflow.EnsureDefault(r.Context(), h.Queries, wsUUID); err != nil {
slog.Warn("ListIssueWorkflows ensure default failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to list workflows")
return
}
}
rows, err := h.Queries.ListIssueWorkflows(r.Context(), wsUUID)
if err != nil {
slog.Warn("ListIssueWorkflows failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to list workflows")
return
}
byWorkflow, err := h.projectIDsByWorkflow(r.Context(), wsUUID, rows)
if err != nil {
slog.Warn("ListIssueWorkflows projects failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to list workflows")
return
}
resp := make([]IssueWorkflowResponse, 0, len(rows))
for _, row := range rows {
def, err := issueworkflow.Decode(row)
if err != nil {
slog.Warn("ListIssueWorkflows decode failed", append(logger.RequestAttrs(r), "workflow_id", uuidToString(row.ID), "error", err)...)
continue
}
resp = append(resp, issueWorkflowToResponse(row, def, byWorkflow[uuidToString(row.ID)]))
}
writeJSON(w, http.StatusOK, map[string]any{"workflows": resp, "total": len(resp)})
}
func (h *Handler) GetIssueWorkflow(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workflow id")
if !ok {
return
}
row, err := h.Queries.GetIssueWorkflow(r.Context(), db.GetIssueWorkflowParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
h.writeIssueWorkflow(w, r, http.StatusOK, row)
}
func (h *Handler) writeIssueWorkflow(w http.ResponseWriter, r *http.Request, status int, row db.IssueWorkflow) {
writeJSON(w, status, h.issueWorkflowResponse(r.Context(), row))
}
func (h *Handler) issueWorkflowResponse(ctx context.Context, row db.IssueWorkflow) IssueWorkflowResponse {
def, err := issueworkflow.Decode(row)
if err != nil {
def = issueworkflow.Definition{}
}
rows, err := h.Queries.ListIssueWorkflows(ctx, row.WorkspaceID)
if err != nil {
rows = []db.IssueWorkflow{row}
}
byWorkflow, err := h.projectIDsByWorkflow(ctx, row.WorkspaceID, rows)
if err != nil {
byWorkflow = map[string][]string{}
}
return issueWorkflowToResponse(row, def, byWorkflow[uuidToString(row.ID)])
}
// NewWorkflowStatus creates a catalog status as part of a workflow write, so
// a column can be added without the status settings page.
type NewWorkflowStatus struct {
Name string `json:"name"`
Category string `json:"category"`
Color string `json:"color"`
}
// IssueWorkflowStepInput names a step by an existing status key or by a
// status to create, with its handoff. Next and back statuses must name
// existing statuses.
type IssueWorkflowStepInput struct {
StatusKey string `json:"status_key,omitempty"`
NewStatus *NewWorkflowStatus `json:"new_status,omitempty"`
Handler *IssueWorkflowHandlerPayload `json:"handler,omitempty"`
Instructions string `json:"instructions,omitempty"`
NextStatusKey string `json:"next_status_key,omitempty"`
BackStatusKey string `json:"back_status_key,omitempty"`
}
type IssueWorkflowWriteRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
// Steps replace the workflow's steps and handoffs. Todo, done and
// cancelled are added when missing; backlog and blocked are listed only
// when included. Fixed statuses take their fixed places.
Steps *[]IssueWorkflowStepInput `json:"steps"`
// StatusMapping moves issues off statuses an edit removes, keyed by the
// removed status. Required for every removed status that issues of the
// workflow's projects still use.
StatusMapping map[string]string `json:"status_mapping,omitempty"`
// DryRun validates the edit and returns the mapping plan without writing.
DryRun bool `json:"dry_run,omitempty"`
}
var newStatusDefaultColor = map[string]string{
"unstarted": "#94a3b8",
"started": "#3b82f6",
"done": "#22c55e",
"closed": "#64748b",
}
// resolveStepInputs turns step inputs into steps, creating the new statuses
// they name. The caller holds the exclusive catalog lock. A non-empty message
// is a caller error.
func resolveStepInputs(ctx context.Context, qtx *db.Queries, wsUUID pgtype.UUID, inputs []IssueWorkflowStepInput) ([]issueworkflow.Step, []db.IssueStatus, string, error) {
steps := make([]issueworkflow.Step, 0, len(inputs))
var created []db.IssueStatus
for _, in := range inputs {
step := issueworkflow.Step{
StatusKey: in.StatusKey,
Instructions: in.Instructions,
NextStatusKey: in.NextStatusKey,
BackStatusKey: in.BackStatusKey,
}
if in.Handler != nil {
step.Handler = issueworkflow.Handler{Type: in.Handler.Type, ID: in.Handler.ID}
}
if in.NewStatus != nil {
entry, msg, err := createWorkflowStatus(ctx, qtx, wsUUID, *in.NewStatus)
if msg != "" || err != nil {
return nil, nil, msg, err
}
created = append(created, entry)
step.StatusKey = entry.Key
}
steps = append(steps, step)
}
return steps, created, "", nil
}
func createWorkflowStatus(ctx context.Context, qtx *db.Queries, wsUUID pgtype.UUID, in NewWorkflowStatus) (db.IssueStatus, string, error) {
name := strings.TrimSpace(in.Name)
if name == "" || utf8.RuneCountInString(name) > 64 {
return db.IssueStatus{}, "status name must be 1-64 characters", nil
}
category, ok := issuestatus.ParseCategory(in.Category)
if !ok {
category = "started"
}
color := newStatusDefaultColor[category]
if strings.TrimSpace(in.Color) != "" {
c, err := normalizeColor(in.Color)
if err != nil {
return db.IssueStatus{}, err.Error(), nil
}
color = c
}
// A status with this name already exists: reuse it instead of failing,
// so the same template applied twice lands on the same statuses.
entries, err := qtx.ListIssueStatusEntries(ctx, db.ListIssueStatusEntriesParams{WorkspaceID: wsUUID, IncludeArchived: false})
if err != nil {
return db.IssueStatus{}, "", err
}
for _, e := range entries {
if strings.EqualFold(e.Name, name) {
return e, "", nil
}
}
entry, msg, err := createIssueStatusEntryLocked(ctx, qtx, wsUUID, category, db.CreateIssueStatusEntryParams{
WorkspaceID: wsUUID,
Name: name,
Category: category,
Color: color,
})
if err != nil && isUniqueViolation(err) {
return db.IssueStatus{}, fmt.Sprintf("a status named %q already exists", name), nil
}
return entry, msg, err
}
// validateHandoffHandlers checks that each concrete handler exists in the
// workspace and that the caller may hand work to it — the same gate an
// assignment passes. Only changed handlers are checked, so an edit never
// fails on a handler someone else picked.
func (h *Handler) validateHandoffHandlers(ctx context.Context, r *http.Request, workspaceID string, steps, previous []issueworkflow.Step) (int, string) {
before := map[issueworkflow.Handler]bool{}
for _, s := range previous {
before[s.Handler] = true
}
for _, s := range steps {
if before[s.Handler] {
continue
}
if status, msg := h.validateHandoffHandler(ctx, r, workspaceID, s); status != 0 {
return status, msg
}
}
return 0, ""
}
// validateHandoffHandler checks one step's handler like
// validateHandoffHandlers.
func (h *Handler) validateHandoffHandler(ctx context.Context, r *http.Request, workspaceID string, s issueworkflow.Step) (int, string) {
switch s.Handler.Type {
case issueworkflow.HandlerAgent, issueworkflow.HandlerSquad, issueworkflow.HandlerMember:
id, err := parseUUIDString(s.Handler.ID)
if err != nil {
return http.StatusBadRequest, "invalid handler id"
}
return h.validateAssigneePair(ctx, r, workspaceID, pgtype.Text{String: s.Handler.Type, Valid: true}, id)
}
return 0, ""
}
func parseUUIDString(s string) (pgtype.UUID, error) {
var id pgtype.UUID
if err := id.Scan(s); err != nil || !id.Valid {
return pgtype.UUID{}, errors.New("invalid uuid")
}
return id, nil
}
func (h *Handler) CreateIssueWorkflow(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
if !h.requireProjectWorkflowsV1(w, r) {
return
}
var req IssueWorkflowWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !isWorkspaceAdmin(member) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
var name, description string
if req.Name != nil {
name = *req.Name
}
if req.Description != nil {
description = *req.Description
}
var inputs []IssueWorkflowStepInput
if req.Steps != nil {
inputs = *req.Steps
}
ctx := r.Context()
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
if err := qtx.LockIssueStatusCatalog(ctx, wsUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
// Store Default before creating statuses for this workflow, so they
// never join Default's snapshot.
if _, err := issueworkflow.EnsureDefault(ctx, qtx, wsUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
steps, createdStatuses, msg, err := resolveStepInputs(ctx, qtx, wsUUID, inputs)
if msg != "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_workflow", msg)
return
}
if err != nil {
slog.Warn("CreateIssueWorkflow statuses failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
name, description, steps = issueworkflow.Normalize(name, description, steps)
catalog, err := h.workflowStatusCatalog(ctx, qtx, wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load issue statuses")
return
}
if err := issueworkflow.Validate(name, description, steps, false, catalog); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_workflow", err.Error())
return
}
if status, msg := h.validateHandoffHandlers(ctx, r, workspaceID, steps, nil); status != 0 {
writeErrorCode(w, status, "invalid_handoff", msg)
return
}
raw, err := issueworkflow.EncodeSteps(steps)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
row, err := qtx.CreateIssueWorkflow(ctx, db.CreateIssueWorkflowParams{
WorkspaceID: wsUUID,
Name: name,
Description: description,
InitialStatusKey: issueworkflow.StartStatus,
Steps: raw,
})
if err != nil {
if isUniqueViolation(err) {
writeErrorCode(w, http.StatusConflict, "workflow_name_taken", "a workflow with this name already exists")
return
}
slog.Warn("CreateIssueWorkflow failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
if len(createdStatuses) > 0 {
h.publishIssueStatusChanged(workspaceID, member, "created")
}
h.publishIssueWorkflowChanged(workspaceID, member, "created")
h.writeIssueWorkflow(w, r, http.StatusCreated, row)
}
// mappingPlan computes which statuses issues of projectIDs are on that
// allowed() rejects, with a suggested target for each.
func mappingPlan(counts []db.CountProjectIssuesByStatusRow, allowed func(string) bool, suggest func(string) string) workflowMappingPlan {
plan := workflowMappingPlan{Required: []StatusMappingRequirement{}, Unchanged: []StatusIssueCount{}}
for _, c := range counts {
plan.TotalIssues += c.IssueCount
if allowed(c.Status) {
plan.Unchanged = append(plan.Unchanged, StatusIssueCount{StatusKey: c.Status, IssueCount: c.IssueCount})
continue
}
plan.AffectedIssues += c.IssueCount
plan.Required = append(plan.Required, StatusMappingRequirement{
StatusKey: c.Status,
IssueCount: c.IssueCount,
SuggestedStatusKey: suggest(c.Status),
})
}
sort.Slice(plan.Required, func(i, j int) bool { return plan.Required[i].StatusKey < plan.Required[j].StatusKey })
return plan
}
// checkMapping verifies a mapping covers every required status with a target
// the destination accepts. It returns the first problem as a message.
func checkMapping(plan workflowMappingPlan, mapping map[string]string, targetAllowed func(string) bool) string {
for _, req := range plan.Required {
target, ok := mapping[req.StatusKey]
if !ok || target == "" {
return fmt.Sprintf("choose a destination status for the %d issue(s) on %q", req.IssueCount, req.StatusKey)
}
if !targetAllowed(target) {
return fmt.Sprintf("destination status %q for %q is not part of the target workflow", target, req.StatusKey)
}
}
return ""
}
func writeMappingRequired(w http.ResponseWriter, plan workflowMappingPlan, msg string) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": msg,
"code": "workflow_status_mapping_required",
"plan": plan,
})
}
// remapIssues applies a validated mapping inside the caller's transaction.
// An issue it moves into a done or closed status ends its wakeups, as any
// status write does; the returned runs are for the caller's post-commit
// broadcast.
func remapIssues(ctx context.Context, qtx *db.Queries, wsUUID pgtype.UUID, projectIDs []pgtype.UUID, plan workflowMappingPlan, mapping map[string]string) ([]db.Issue, []db.AgentTaskQueue, error) {
var changed []db.Issue
var cancelled []db.AgentTaskQueue
for _, req := range plan.Required {
rows, err := qtx.RemapProjectIssueStatus(ctx, db.RemapProjectIssueStatusParams{
WorkspaceID: wsUUID,
ProjectIds: projectIDs,
FromStatus: req.StatusKey,
ToStatus: mapping[req.StatusKey],
})
if err != nil {
return nil, nil, err
}
for _, issue := range rows {
stopped, err := service.StopClosedIssueWakeups(ctx, qtx, issue)
if err != nil {
return nil, nil, err
}
cancelled = append(cancelled, stopped...)
}
changed = append(changed, rows...)
}
return changed, cancelled, nil
}
func (h *Handler) UpdateIssueWorkflow(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workflow id")
if !ok {
return
}
var req IssueWorkflowWriteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
// The exclusive catalog lock keeps a status archive from racing the step
// validation below, and serializes concurrent edits of one workspace's
// workflows.
if err := qtx.LockIssueStatusCatalog(ctx, wsUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
current, err := qtx.GetIssueWorkflow(ctx, db.GetIssueWorkflowParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
var projects []db.Project
if current.IsDefault {
projects, err = qtx.ListProjectsUsingDefaultIssueWorkflow(ctx, wsUUID)
} else {
projects, err = qtx.ListProjectsUsingIssueWorkflow(ctx, db.ListProjectsUsingIssueWorkflowParams{WorkspaceID: wsUUID, WorkflowID: current.ID})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
currentSteps, err := issueworkflow.DecodeSteps(current.Steps)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read workflow")
return
}
name, description, steps := current.Name, current.Description, currentSteps
if req.Name != nil && !current.IsDefault {
name = *req.Name
}
if req.Description != nil {
description = *req.Description
}
var createdStatuses []db.IssueStatus
if req.Steps != nil {
var msg string
steps, createdStatuses, msg, err = resolveStepInputs(ctx, qtx, wsUUID, *req.Steps)
if msg != "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_workflow", msg)
return
}
if err != nil {
slog.Warn("UpdateIssueWorkflow statuses failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
}
name, description, steps = issueworkflow.Normalize(name, description, steps)
catalog, err := h.workflowStatusCatalog(ctx, qtx, wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load issue statuses")
return
}
if err := issueworkflow.Validate(name, description, steps, current.IsDefault, catalog); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_workflow", err.Error())
return
}
if status, msg := h.validateHandoffHandlers(ctx, r, workspaceID, steps, currentSteps); status != 0 {
writeErrorCode(w, status, "invalid_handoff", msg)
return
}
// Default accepts every status, so editing it never moves issues; issues
// on a status it drops stay there and show under hidden columns.
plan := workflowMappingPlan{Required: []StatusMappingRequirement{}, Unchanged: []StatusIssueCount{}}
projectIDs := make([]pgtype.UUID, len(projects))
for i, p := range projects {
projectIDs[i] = p.ID
}
if !current.IsDefault && len(projectIDs) > 0 {
next := issueworkflow.Definition{ID: current.ID, Name: name, Steps: steps}
counts, err := qtx.CountProjectIssuesByStatus(ctx, db.CountProjectIssuesByStatusParams{WorkspaceID: wsUUID, ProjectIds: projectIDs})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
categoryOf := func(key string) string { return catalog[key].Category }
plan = mappingPlan(counts, next.Has, func(key string) string { return issueworkflow.MoveTarget(next, key, categoryOf) })
if req.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "plan": plan})
return
}
if msg := checkMapping(plan, req.StatusMapping, next.Has); msg != "" {
writeMappingRequired(w, plan, msg)
return
}
} else if req.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "plan": plan})
return
}
raw, err := issueworkflow.EncodeSteps(steps)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
row, err := qtx.UpdateIssueWorkflow(ctx, db.UpdateIssueWorkflowParams{
ID: current.ID,
WorkspaceID: wsUUID,
Name: name,
Description: description,
InitialStatusKey: issueworkflow.StartStatus,
Steps: raw,
})
if err != nil {
if isUniqueViolation(err) {
writeErrorCode(w, http.StatusConflict, "workflow_name_taken", "a workflow with this name already exists")
return
}
slog.Warn("UpdateIssueWorkflow failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
changed, cancelledWakeups, err := remapIssues(ctx, qtx, wsUUID, projectIDs, plan, req.StatusMapping)
if err != nil {
slog.Warn("UpdateIssueWorkflow remap failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to move issues to the new statuses")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save workflow")
return
}
// Remapping is an administrative move: snapshots only, so it neither
// notifies subscribers per issue nor looks like a handoff.
h.publishIssueSnapshots(ctx, changed, "member", uuidToString(member.UserID))
h.broadcastCancelledWakeups(ctx, wsUUID, cancelledWakeups)
if len(createdStatuses) > 0 {
h.publishIssueStatusChanged(workspaceID, member, "created")
}
h.publishIssueWorkflowChanged(workspaceID, member, "updated")
h.writeIssueWorkflow(w, r, http.StatusOK, row)
}
func (h *Handler) DeleteIssueWorkflow(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workflow id")
if !ok {
return
}
ctx := r.Context()
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete workflow")
return
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
if err := qtx.LockIssueStatusCatalog(ctx, wsUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete workflow")
return
}
row, err := qtx.GetIssueWorkflow(ctx, db.GetIssueWorkflowParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
if row.IsDefault {
writeErrorCode(w, http.StatusBadRequest, "workflow_is_default", "the Default workflow cannot be deleted")
return
}
projects, err := qtx.ListProjectsUsingIssueWorkflow(ctx, db.ListProjectsUsingIssueWorkflowParams{WorkspaceID: wsUUID, WorkflowID: idUUID})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete workflow")
return
}
if len(projects) > 0 {
writeJSON(w, http.StatusConflict, map[string]any{
"error": fmt.Sprintf("%d project(s) still use this workflow; switch them to another workflow first", len(projects)),
"code": "workflow_in_use",
"project_count": len(projects),
})
return
}
n, err := qtx.DeleteIssueWorkflow(ctx, db.DeleteIssueWorkflowParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete workflow")
return
}
if n == 0 {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete workflow")
return
}
h.publishIssueWorkflowChanged(workspaceID, member, "deleted")
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) publishIssueWorkflowChanged(workspaceID string, actor db.Member, action string) {
h.publish(protocol.EventIssueWorkflowChanged, workspaceID, "member", uuidToString(actor.UserID), map[string]any{
"action": action,
})
}
// publishProjectWorkflowUpdate announces a project whose workflow settings
// changed and returns the project as the API shapes it.
func (h *Handler) publishProjectWorkflowUpdate(ctx context.Context, workspaceID string, member db.Member, project db.Project) ProjectResponse {
resp := projectToResponse(project)
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(ctx, project.WorkspaceID, project.ID)
resp.ResourceCount = h.loadProjectResourceCount(ctx, project.ID)
h.publish(protocol.EventProjectUpdated, workspaceID, "member", uuidToString(member.UserID), map[string]any{"project": resp})
return resp
}
// loadProjectForWorkflowWrite resolves the {id} project and checks that the
// caller may change its workflow settings.
func (h *Handler) loadProjectForWorkflowWrite(w http.ResponseWriter, r *http.Request) (db.Project, db.Member, string, bool) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return db.Project{}, db.Member{}, "", false
}
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return db.Project{}, db.Member{}, "", false
}
projectUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "project id")
if !ok {
return db.Project{}, db.Member{}, "", false
}
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ID: projectUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return db.Project{}, db.Member{}, "", false
}
if !canManageProjectWorkflow(member, project) {
writeError(w, http.StatusForbidden, "only workspace admins and the project lead can change this project's workflow")
return db.Project{}, db.Member{}, "", false
}
return project, member, workspaceID, true
}
type SetProjectWorkflowRequest struct {
// WorkflowID is the workflow to use; null switches back to the workspace
// Default workflow.
WorkflowID *string `json:"workflow_id"`
StatusMapping map[string]string `json:"status_mapping,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
// SetProjectWorkflow switches the workflow a project uses. Issues on statuses
// the new workflow does not list move to the statuses the caller maps them to,
// in the same transaction. The move is administrative: it neither reassigns
// issues nor starts runs.
func (h *Handler) SetProjectWorkflow(w http.ResponseWriter, r *http.Request) {
loaded, member, workspaceID, ok := h.loadProjectForWorkflowWrite(w, r)
if !ok {
return
}
wsUUID := loaded.WorkspaceID
var req SetProjectWorkflowRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var targetID pgtype.UUID
if req.WorkflowID != nil && *req.WorkflowID != "" {
if !h.requireProjectWorkflowsV1(w, r) {
return
}
id, ok := parseUUIDOrBadRequest(w, *req.WorkflowID, "workflow_id")
if !ok {
return
}
targetID = id
}
ctx := r.Context()
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to change workflow")
return
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
// Shared catalog lock: a workflow edit (exclusive) cannot change the
// target's steps between the census and the remap below.
if err := qtx.LockIssueStatusCatalogShared(ctx, wsUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to change workflow")
return
}
project, err := qtx.LockProjectForWorkflowChange(ctx, db.LockProjectForWorkflowChangeParams{ID: loaded.ID, WorkspaceID: wsUUID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "project not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to change workflow")
return
}
var target *issueworkflow.Definition
if targetID.Valid {
row, err := qtx.GetIssueWorkflow(ctx, db.GetIssueWorkflowParams{ID: targetID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusBadRequest, "workflow not found in this workspace")
return
}
def, err := issueworkflow.Decode(row)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read workflow")
return
}
if def.IsDefault {
// Choosing the Default row is the same as choosing Default.
targetID = pgtype.UUID{}
} else {
target = &def
}
}
catalog, err := h.workflowStatusCatalog(ctx, qtx, wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load issue statuses")
return
}
// The Default workflow accepts every catalog status, archived included:
// issues already on an archived status keep it, as they always have.
allowed := func(key string) bool {
if target == nil {
_, ok := catalog[key]
return ok
}
return target.Has(key)
}
targetAccepts := func(key string) bool {
if target == nil {
entry, ok := catalog[key]
return ok && !entry.Archived
}
return target.Has(key)
}
counts, err := qtx.CountProjectIssuesByStatus(ctx, db.CountProjectIssuesByStatusParams{WorkspaceID: wsUUID, ProjectIds: []pgtype.UUID{project.ID}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to change workflow")
return
}
categoryOf := func(key string) string { return catalog[key].Category }
plan := mappingPlan(counts, allowed, func(key string) string {
if target == nil {
return issuestatus.Todo
}
return issueworkflow.MoveTarget(*target, key, categoryOf)
})
if req.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "plan": plan})
return
}
if msg := checkMapping(plan, req.StatusMapping, targetAccepts); msg != "" {
writeMappingRequired(w, plan, msg)
return
}
updated, err := qtx.SetProjectWorkflow(ctx, db.SetProjectWorkflowParams{ID: project.ID, WorkspaceID: wsUUID, WorkflowID: targetID})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to change workflow")
return
}
changed, cancelledWakeups, err := remapIssues(ctx, qtx, wsUUID, []pgtype.UUID{project.ID}, plan, req.StatusMapping)
if err != nil {
slog.Warn("SetProjectWorkflow remap failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to move issues to the new statuses")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "failed to change workflow")
return
}
h.publishIssueSnapshots(ctx, changed, "member", uuidToString(member.UserID))
h.broadcastCancelledWakeups(ctx, wsUUID, cancelledWakeups)
resp := h.publishProjectWorkflowUpdate(ctx, workspaceID, member, updated)
writeJSON(w, http.StatusOK, map[string]any{
"project": resp,
"updated_issues": len(changed),
"plan": plan,
})
}
// SetIssueWorkflowHandoffRequest is a column's handoff. CopyForProjectID,
// when set, leaves the workflow as it is and saves the handoff on a copy of
// it that only that project uses.
type SetIssueWorkflowHandoffRequest struct {
IssueWorkflowStepPayload
CopyForProjectID string `json:"copy_for_project_id,omitempty"`
}
// SetIssueWorkflowHandoff saves what a workflow does when an issue enters
// one of its columns: who it is handed to, the instructions their run gets,
// and where the handler moves it next. It applies to every project using the
// workflow, unless the caller asks for a copy for one project: the project
// then switches to the copy in the same transaction. The copy lists the same
// statuses, so no issue moves.
func (h *Handler) SetIssueWorkflowHandoff(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workflow id")
if !ok {
return
}
var req SetIssueWorkflowHandoffRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
step := issueworkflow.NormalizeHandoff(payloadToStep(chi.URLParam(r, "status"), req.IssueWorkflowStepPayload))
if (step.HandsOff() || req.CopyForProjectID != "") && !h.requireProjectWorkflowsV1(w, r) {
return
}
var copyFor pgtype.UUID
if req.CopyForProjectID != "" {
copyFor, ok = parseUUIDOrBadRequest(w, req.CopyForProjectID, "copy_for_project_id")
if !ok {
return
}
}
ctx := r.Context()
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
if err := qtx.LockIssueStatusCatalog(ctx, wsUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
current, err := qtx.GetIssueWorkflow(ctx, db.GetIssueWorkflowParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
def, err := issueworkflow.Decode(current)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read workflow")
return
}
if err := issueworkflow.ValidateHandoff(step, def); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_handoff", err.Error())
return
}
if status, msg := h.validateHandoffHandlers(ctx, r, workspaceID, []issueworkflow.Step{step}, def.Steps); status != 0 {
writeErrorCode(w, status, "invalid_handoff", msg)
return
}
for i, s := range def.Steps {
if s.StatusKey == step.StatusKey {
def.Steps[i] = step
}
}
raw, err := issueworkflow.EncodeSteps(def.Steps)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
if copyFor.Valid {
h.saveHandoffOnProjectCopy(w, r, qtx, tx, member, workspaceID, current, raw, copyFor)
return
}
row, err := qtx.UpdateIssueWorkflow(ctx, db.UpdateIssueWorkflowParams{
ID: current.ID,
WorkspaceID: wsUUID,
Name: current.Name,
Description: current.Description,
InitialStatusKey: issueworkflow.StartStatus,
Steps: raw,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
h.publishIssueWorkflowChanged(workspaceID, member, "updated")
h.writeIssueWorkflow(w, r, http.StatusOK, row)
}
// saveHandoffOnProjectCopy finishes SetIssueWorkflowHandoff for a copy:
// steps already carry the new handoff. The caller holds the exclusive catalog
// lock in tx.
func (h *Handler) saveHandoffOnProjectCopy(w http.ResponseWriter, r *http.Request, qtx *db.Queries, tx pgx.Tx, member db.Member, workspaceID string, source db.IssueWorkflow, steps []byte, projectID pgtype.UUID) {
ctx := r.Context()
if source.IsDefault {
writeErrorCode(w, http.StatusBadRequest, "invalid_handoff", "the Default workflow cannot be copied for one project")
return
}
project, err := qtx.LockProjectForWorkflowChange(ctx, db.LockProjectForWorkflowChangeParams{ID: projectID, WorkspaceID: source.WorkspaceID})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
if project.WorkflowID != source.ID {
writeErrorCode(w, http.StatusConflict, "invalid_handoff", "the project does not use this workflow")
return
}
existing, err := qtx.ListIssueWorkflows(ctx, source.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
taken := make(map[string]bool, len(existing))
for _, row := range existing {
taken[strings.ToLower(row.Name)] = true
}
row, err := qtx.CreateIssueWorkflow(ctx, db.CreateIssueWorkflowParams{
WorkspaceID: source.WorkspaceID,
Name: projectCopyName(source.Name, project.Title, taken),
Description: source.Description,
InitialStatusKey: issueworkflow.StartStatus,
Steps: steps,
})
if err != nil {
slog.Warn("SetIssueWorkflowHandoff copy failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
updated, err := qtx.SetProjectWorkflow(ctx, db.SetProjectWorkflowParams{ID: project.ID, WorkspaceID: source.WorkspaceID, WorkflowID: row.ID})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save handoff")
return
}
h.publishIssueWorkflowChanged(workspaceID, member, "created")
h.publishProjectWorkflowUpdate(ctx, workspaceID, member, updated)
h.writeIssueWorkflow(w, r, http.StatusCreated, row)
}
// projectCopyName names a workflow copied for one project, "Workflow
// (Project)", numbered when that name is taken and cut to the name limit.
func projectCopyName(workflow, project string, taken map[string]bool) string {
for n := 1; ; n++ {
suffix := " (" + project + ")"
if n > 1 {
suffix = " (" + project + " " + strconv.Itoa(n) + ")"
}
base := []rune(workflow)
if room := issueworkflow.MaxNameRunes - utf8.RuneCountInString(suffix); room < len(base) {
if room < 1 {
suffix = " " + strconv.Itoa(n)
room = issueworkflow.MaxNameRunes - len(suffix)
}
base = base[:room]
}
name := strings.TrimSpace(string(base)) + suffix
if !taken[strings.ToLower(name)] {
return name
}
}
}
// PreviewIssueWorkflowBriefRequest is a handoff being edited. ProjectID, when
// set, names the project the preview is for.
type PreviewIssueWorkflowBriefRequest struct {
IssueWorkflowStepPayload
ProjectID string `json:"project_id,omitempty"`
}
// PreviewIssueWorkflowBrief renders the brief a column's handler would
// receive, for a handoff being edited before it is saved.
func (h *Handler) PreviewIssueWorkflowBrief(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workflow id")
if !ok {
return
}
var req PreviewIssueWorkflowBriefRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
row, err := h.Queries.GetIssueWorkflow(ctx, db.GetIssueWorkflowParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
def, err := issueworkflow.Decode(row)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read workflow")
return
}
projectName := ""
if req.ProjectID != "" {
if id, err := parseUUIDString(req.ProjectID); err == nil {
if p, err := h.Queries.GetProjectInWorkspace(ctx, db.GetProjectInWorkspaceParams{ID: id, WorkspaceID: wsUUID}); err == nil {
projectName = p.Title
}
}
}
step := issueworkflow.NormalizeHandoff(payloadToStep(req.StatusKey, req.IssueWorkflowStepPayload))
if !def.Has(step.StatusKey) {
writeError(w, http.StatusBadRequest, "status_key is not a column of this workflow")
return
}
// The brief names the draft's handler, so an unchecked id would let the
// caller read the name of a user outside this workspace.
if status, msg := h.validateHandoffHandlers(ctx, r, workspaceID, []issueworkflow.Step{step}, def.Steps); status != 0 {
writeError(w, status, msg)
return
}
for i, s := range def.Steps {
if s.StatusKey == step.StatusKey {
def.Steps[i] = step
}
}
resolver := issuestatus.NewResolver(wsUUID)
brief := issueworkflow.Brief(issueworkflow.BriefInput{
Workflow: def,
ProjectName: projectName,
StatusKey: step.StatusKey,
IssueIdentifier: h.getIssuePrefix(ctx, wsUUID) + "-123",
StatusName: func(key string) string {
return resolver.Name(ctx, h.Queries, key)
},
HandlerName: func(s issueworkflow.Step) string {
switch s.Handler.Type {
case issueworkflow.HandlerAgent, issueworkflow.HandlerSquad, issueworkflow.HandlerMember:
id, err := parseUUIDString(s.Handler.ID)
if err != nil {
return s.Handler.Type
}
return h.actorDisplayName(ctx, wsUUID, s.Handler.Type, id)
case issueworkflow.HandlerProjectLead:
return "the project lead"
case issueworkflow.HandlerCreator:
return "the issue creator"
}
return ""
},
})
writeJSON(w, http.StatusOK, map[string]any{"brief": brief})
}
// WorkflowHandoffRun is an active run of the agent or squad a handoff takes
// the issue from.
type WorkflowHandoffRun struct {
TaskID string `json:"task_id"`
AgentID string `json:"agent_id"`
Status string `json:"status"`
StartedAt *string `json:"started_at"`
}
type WorkflowHandoffPreviewResponse struct {
Handoff bool `json:"handoff"`
WorkflowName string `json:"workflow_name,omitempty"`
FromStatus string `json:"from_status"`
ToStatus string `json:"to_status"`
HandlerType string `json:"handler_type,omitempty"`
HandlerID string `json:"handler_id,omitempty"`
PreviousAssigneeType *string `json:"previous_assignee_type"`
PreviousAssigneeID *string `json:"previous_assignee_id"`
PreviousRuns []WorkflowHandoffRun `json:"previous_runs"`
Brief string `json:"brief,omitempty"`
}
// PreviewWorkflowHandoff says what moving an issue to a status would do under
// its project's workflow, for the confirmation shown before a handoff: who
// the issue would go to, which runs of its current agent are still active,
// and the brief the new handler would receive. With project_id it previews
// moving the issue into that project and entering the step it lands on
// (status then optional). It writes nothing.
func (h *Handler) PreviewWorkflowHandoff(w http.ResponseWriter, r *http.Request) {
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
status := r.URL.Query().Get("status")
projectParam := r.URL.Query().Get("project_id")
if status == "" && projectParam == "" {
writeError(w, http.StatusBadRequest, "status is required")
return
}
params := db.UpdateIssueParams{
ProjectID: issue.ProjectID,
AssigneeType: issue.AssigneeType,
AssigneeID: issue.AssigneeID,
}
if status != "" {
params.Status = pgtype.Text{String: status, Valid: true}
}
enterOnMove := false
if projectParam != "" {
projectID, ok := parseUUIDOrBadRequest(w, projectParam, "project_id")
if !ok {
return
}
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ID: projectID, WorkspaceID: issue.WorkspaceID}); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
params.ProjectID = projectID
enterOnMove = true
}
hand, err := h.applyIssueWorkflow(r.Context(), issue, &params, status != "", false, enterOnMove, nil)
if err != nil {
if writeIssueWorkflowError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "failed to resolve project workflow")
return
}
if params.Status.Valid {
status = params.Status.String
} else {
status = issue.Status
}
resp := WorkflowHandoffPreviewResponse{
FromStatus: issue.Status,
ToStatus: status,
PreviousAssigneeType: textToPtr(issue.AssigneeType),
PreviousAssigneeID: uuidToPtr(issue.AssigneeID),
PreviousRuns: []WorkflowHandoffRun{},
}
if hand == nil {
writeJSON(w, http.StatusOK, resp)
return
}
resp.WorkflowName = hand.def.Name
resp.Handoff = true
resp.HandlerType = params.AssigneeType.String
resp.HandlerID = uuidToString(params.AssigneeID)
next := issue
next.Status = status
next.ProjectID = params.ProjectID
next.AssigneeType = params.AssigneeType
next.AssigneeID = params.AssigneeID
resp.Brief = h.workflowHandoffBrief(r.Context(), next, hand)
if issue.AssigneeType != params.AssigneeType || issue.AssigneeID != params.AssigneeID {
for _, task := range h.assigneeActiveRuns(r.Context(), issue) {
started := task.StartedAt
if !started.Valid {
started = task.CreatedAt
}
resp.PreviousRuns = append(resp.PreviousRuns, WorkflowHandoffRun{
TaskID: uuidToString(task.ID),
AgentID: uuidToString(task.AgentID),
Status: task.Status,
StartedAt: timestampToPtr(started),
})
}
}
writeJSON(w, http.StatusOK, resp)
}